Skip to content

feat(sync): show the media sync status in Sync settings - #22832

Open
criticalAY wants to merge 1 commit into
ankidroid:mainfrom
criticalAY:feat/media-sync-status
Open

criticalAY wants to merge 1 commit into
ankidroid:mainfrom
criticalAY:feat/media-sync-status

Conversation

@criticalAY

Copy link
Copy Markdown
Contributor

Note

Assisted-by: Opus 5.5

Purpose / Description

There was no way to tell whether media had finished syncing, so a missing image left people guessing. The app now records when each media sync ends and whether it finished, and shows that as a "Media sync" row in Sync settings: syncing, waiting for Wi-Fi, didn't finish or up to date, with the time. Tapping the row starts a media sync, with the usual metered warning.

Fixes

Approach

check commit message

How Has This Been Tested? [I am open to suggestions here]

update to date:
Screenshot 2026-10-27 at 11 43 23 PM

didnt finish:
Screenshot 2026-10-27 at 11 44 03 PM

waiting for connection:
Screenshot 2026-10-27 at 11 44 35 PM

waiting for wifi:
Screenshot 2026-10-27 at 11 45 00 PM

not logged in:
Screenshot 2026-10-27 at 11 45 12 PM

Learning (optional, can help others)

NA

Checklist

Please, go through these checks before submitting the PR.

  • You have a descriptive commit message with a short title (first line, max 50 chars).
  • You have commented your code, particularly in hard-to-understand areas
  • You have performed a self-review of your own code
  • UI changes: include screenshots of all affected screens (in particular showing any new or changed strings)
  • UI Changes: You have tested your change using the Google Accessibility Scanner

@github-actions

Copy link
Copy Markdown
Contributor

Important

Maintainers: This PR contains Strings changes

  1. Sync Translations before merging this PR and wait for the action to complete
  2. Review and merge the auto-generated PR in order to sync all user-submitted translations
  3. Sync Translations again and merge the PR so the huge automated string changes caused by merging this PR are by themselves and easy to review

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Snapshot diff report vs main. Open screenshot-diff for diffs.

  • MediaSyncBottomSheetScreenshotTest: 1 change
All 1 changed screenshots

MediaSyncBottomSheetScreenshotTest

  • media_sync_incomplete_compare.png

@david-allison

Copy link
Copy Markdown
Member
  • Didn't finish => incomplete.

@david-allison david-allison left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the abstraction... I think I like the placement.

Pinging @BrayanDSO as this impacts settings.

Ping me for a deeper review in 24h, I need some time to think about this

@david-allison david-allison added the Needs Author Reply Waiting for a reply from the original author label Oct 27, 2026
@david-allison

Copy link
Copy Markdown
Member

Tests are failing. I REALLY like the icons

@criticalAY criticalAY removed the Needs Author Reply Waiting for a reply from the original author label Oct 28, 2026
@criticalAY
criticalAY force-pushed the feat/media-sync-status branch from 1933006 to f6128af Compare October 28, 2026 05:37
@BrayanDSO

Copy link
Copy Markdown
Member

maybe improve the sync dialog? make the button a menu? a bottom sheet dialog?
It should be easier to access and more intuitive than settings

@mikehardy

Copy link
Copy Markdown
Member

Assisted-by: grok-4.7
strings_pass@f6128af405745f56eab004e8549276e9277f921b

Thank you — the status logic is a pure from(...) with tests for login, fetch-never, waiting, and the last attempt, and the worker records completion, failure, and cancel on the real doWork path. I rebased locally onto current main without pushing; lint (lintAll, ktlintCheck, lint-rules:test), unit (jacocoUnitTestReport), and emulator (TEST_RELEASE_BUILD=true, emulator-5556 / API35_GAPI) passed. GitHub CI on this tip is green.

mediaSyncStatus(work) calls MediaSyncStatus.from(...) with login, fetch-never, work state, and the stored last attempt; SyncSettingsFragment adds a non-persistent row under the account preference, collects unique SYNC_MEDIA work, and starts sync via MeteredSyncPolicy when canSync. Keys live in common/android preferences_sync.xml via CommonPreferenceKey; SyncMediaWorker records attempt success or failure on the existing paths. New strings cover Media sync, syncing/waiting/off states, and formatted incomplete/up-to-date summaries; tests cover from, the fragment, worker recording, analytics exclusion, and a screenshot for incomplete.

  1. Sync Translations before merging this PR and wait for the action to complete
  2. Review and merge the auto-generated PR in order to sync all user-submitted translations
  3. Sync Translations again and merge the PR so the huge automated string changes caused by merging this PR are by themselves and easy to review

some acceptable findings, no action required, just leaving for posterity: waiting rows stay enabled but ignore taps; Off row looks tappable but ignores taps; “Up to date” means worker success not every missing file; shorter commit subject possible; Approach says “check commit message”; Accessibility Scanner box unchecked.

Screenshot changes have been detected, so leaving this for a good manual look.

@david-allison david-allison added the Needs Author Reply Waiting for a reply from the original author label Nov 2, 2026
@david-allison

Copy link
Copy Markdown
Member

Needs a plan so we can merge/close this. @BrayanDSO @criticalAY

FWIW: I like the design

@david-allison david-allison added the Needs reviewer reply Waiting for a reply from another reviewer label Nov 2, 2026
@criticalAY

Copy link
Copy Markdown
Contributor Author

I will make some changes then lets see but worth getting it in

@BrayanDSO

Copy link
Copy Markdown
Member

I like the idea. Not the place

@criticalAY criticalAY removed the Needs reviewer reply Waiting for a reply from another reviewer label Nov 2, 2026
@criticalAY
criticalAY force-pushed the feat/media-sync-status branch from f6128af to 1fd6579 Compare November 3, 2026 04:48

@mikehardy mikehardy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assisted-by: Grok 4.7

Thanks for moving media sync status off Settings into the deck-picker sheet — that matches the placement thread.

Locally I rebased onto main without force-pushing (prep stayed at the PR tip). I ran lint (lintAll, ktlintCheck, lint-rules:test), unit tests (jacocoUnitTestReport), and release emulator tests (TEST_RELEASE_BUILD=true via run_emulator_tests.py on emulator-5556 / API35_GAPI); all passed.

Requesting changes: Abort only cancels the WorkManager job; it never calls cancelMediaSync, so an in-flight syncMedia keeps running until the blocking call returns. The old dialog aborted the backend on the button. Also, showProgressWhileActive returns on the first inactive progress sample, but the worker is already RUNNING before the backend marks active — an open sheet can miss the added/removed/checked lines for the whole run. Please wire Abort like the old dialog and keep polling until work finishes, not on the first inactive tick.

Plan-wise: worth landing after those fixes as part of #22591; no need to close the PR over placement now that the surface is the sync-button sheet.

@criticalAY
criticalAY force-pushed the feat/media-sync-status branch from 1fd6579 to a2d5046 Compare November 3, 2026 05:15
@criticalAY criticalAY removed the Needs Author Reply Waiting for a reply from the original author label Nov 3, 2026
@criticalAY

criticalAY commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor Author

@BrayanDSO it’s now a bottom sheet from the deck list’s sync button . Since nobody will find a long-press, I’d make the sheet open from where missing media actually shows up: the study screen’s missing media snackbar, the media sync failure notification, and a warning badge on the sync button when the last media sync was incomplete. Tapping the button during a media sync opens it too (it replaces the old progress dialog). Does that work for you?

There was no way to see whether media had finished syncing, so a
missing image left people guessing. Record when each media sync ends
and whether it finished, and show it in a sheet opened from the sync
button: long-press it, or tap it while media is syncing. The sheet
replaces the media sync log dialog, shows progress while syncing, and
offers Sync now once the sync has ended.

Part of 22591

Assisted-by: Claude Opus 5.5
@criticalAY
criticalAY force-pushed the feat/media-sync-status branch from a2d5046 to c3b68a5 Compare November 3, 2026 05:22

@mikehardy mikehardy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assisted-by: grok-4.7

Thank you. The progress loop now keeps reading through an inactive sample, and the test shows the added/removed/checked lines after that first inactive response. The last-attempt record is still on the real doWork path. The strings name the states the code can tell apart, and “Media sync incomplete” matches the wording David asked for.

Git: checked out the PR head and rebased one commit onto main locally (clean replay; no push).

Gradle / AVD: lintAll, ktlintCheck, lint-rules:test; jacocoUnitTestReport; run_emulator_tests.py with TEST_RELEASE_BUILD=true on emulator-5556 / API35_GAPI — all passed.

DeckPicker.setupMediaSyncMenuItem stores a long-click on the sync ActionProvider. If the item is enabled, it shows MediaSyncBottomSheet under tag MediaSyncBottomSheet. onOptionsItemSelected for action_sync opens that sheet when SyncActionProvider.isProgressShown is true (the indicator is set only while work is RUNNING) and otherwise calls sync(). A second show() is skipped when the tag is already added. setTooltipText re-applies the long-click listener because TooltipCompat on API 25 replaces it. setLongClickAction also sets the accessibility long-click label when the button already exists. SyncActionProviderTest covers tooltip-then-long-press on SDK 25 and 36.

showStatus maps mediaSyncStatus(work) onto the icon, summary, indeterminate bar (Syncing only), Sync now (canSync), Log in (NotLoggedIn), and Abort (canAbort). mediaSyncStatus calls MediaSyncStatus.from with login, fetch-never, work state, whether the constraint is UNMETERED, online, metered, and the stored last attempt. Order: logged out wins; RUNNING is Syncing; ENQUEUED while offline is WaitingForNetwork (even if the work also wants unmetered); ENQUEUED on a metered network with NetworkType.UNMETERED is WaitingForUnmeteredNetwork; any other ENQUEUED is Syncing; ShouldFetchMedia.NEVER is Off only once nothing is running or queued; then the stored attempt (time 0 means none) is NotRecorded, Failed, or Completed. BLOCKED and finished states fall through to Off or the stored attempt. The sheet reads getWorkInfosForUniqueWorkFlow(SYNC_MEDIA) with infos.lastOrNull() inside repeatOnLifecycle(STARTED).

While that work is RUNNING, collectLatest calls showMediaSyncProgress. That function polls backend.mediaSyncStatus() every 100ms on Dispatchers.IO and, only when active is true, sets progress to added, removed, and checked. A BackendException is logged and the loop continues. The loop itself does not return. showStatus clears progress visibility on each work-info sample, then the poll shows it again once the backend reports active.

startMediaSync bails if syncAuth() is null, then MeteredSyncPolicy.confirmThen and getNetworkTypeRequiredForSync(forMedia = true, permission) into SyncMediaWorker.start. That is the same confirm-then-enqueue path collection sync uses, including an unmetered constraint when fetch-media is only-unmetered or the user has not allowed metered sync. Log in starts AccountActivity.

lastMediaSyncAttempt reads Prefs.lastMediaSyncTime / lastMediaSyncFailed. recordMediaSync writes both keys in one edit via key(CommonPreferenceKey.…). Time is EpochMilliseconds from TimeManager.time.intTimeMS(), shown with DateUtils.formatDateTime (date, time, abbreviated month). Icons: ic_done completed, ic_sync_problem failed, baseline_wifi_24 waiting for an unmetered network, ic_sync otherwise. The icon is importantForAccessibility="no". The title is an accessibility heading. The title and summary are the spoken state.

Sync.kt deletes monitorMediaSync. That dialog polled the same three lines, called cancelMediaSync from Abort, and snackbarred complete, aborted, or failed when it closed. Continue dismissed without a snackbar. The sheet is the only replacement. Those snackbars are gone. Abort on the sheet does not call cancelMediaSync (blocker below).

Abort does not stop a running media sync. binding.abort still only calls WorkManager.cancelUniqueWork(SYNC_MEDIA). cancelMediaSync (setWantsAbort() and abortMediaSync()) still runs only inside SyncMediaWorker's CancellationException handler, after recordMediaSyncAttempt(failed = true). doWorkHoldingLock calls backend.syncMedia(auth) as a normal blocking call, then delay. Cancellation is not delivered until that call returns, and nothing sets the abort flag while it is in progress. The dialog this sheet deletes called cancelMediaSync from the Abort button itself. MediaSyncBottomSheetTest still aborts an ENQUEUED job, which has not entered syncMedia, so it stays green. Please call cancelMediaSync on Abort click in addition to cancelling the unique work, so the backend stops and the worker records failure instead of success if syncMedia returns after cancel.

@mikehardy mikehardy added the Needs Author Reply Waiting for a reply from the original author label Nov 3, 2026

@david-allison david-allison left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GPT-6.1-Sol xhigh & GPT-6-Astra xhigh

Progress polling consumes media sync failures

Subject: Progress polling consumes media sync failures

When the sheet consumes a transfer error first, the worker records success and reports the failed transfer as up to date.

Ỳö†Ô∏è This issue existed before the PR and should likely be split off into a separate issue.
Previously the dialog reported the failure; this change silently discards it and records completion.

---
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncFailureRegressionTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncFailureRegressionTest.kt
new file mode 100644
index 0000000000..8b280c4699
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncFailureRegressionTest.kt
@@ -0,0 +1,93 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.sync
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.work.Constraints
+import androidx.work.ListenableWorker.Result
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import androidx.work.testing.TestListenableWorkerBuilder
+import anki.sync.mediaSyncStatusResponse
+import anki.sync.syncAuth
+import com.ichi2.anki.CollectionManager
+import com.ichi2.anki.RobolectricTest
+import com.ichi2.anki.settings.Prefs
+import com.ichi2.anki.worker.SyncMediaWorker
+import com.ichi2.anki.worker.UniqueWorkNames
+import com.ichi2.anki.worker.toWorkData
+import com.ichi2.testutils.launchFragment
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.unmockkObject
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runCurrent
+import net.ankiweb.rsdroid.Backend
+import net.ankiweb.rsdroid.BackendException
+import net.ankiweb.rsdroid.BackendFactory
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@RunWith(AndroidJUnit4::class)
+class MediaSyncFailureRegressionTest : RobolectricTest() {
+    @After
+    fun resetOverrides() {
+        BackendFactory.setOverride(null)
+        unmockkObject(WorkManager.Companion)
+    }
+
+    @Test
+    fun `a sheet observing a failed transfer must not turn the worker result into success`() =
+        runTest {
+            CollectionManager.discardBackend()
+            val failurePending = AtomicBoolean(false)
+            val failureObserved = AtomicBoolean(false)
+            BackendFactory.setOverride {
+                object : Backend() {
+                    override fun syncMedia(input: anki.sync.SyncAuth) {
+                        failurePending.set(true)
+                    }
+
+                    override fun mediaSyncStatus(): anki.sync.MediaSyncStatusResponse {
+                        // The native backend takes the finished task when reading its result:
+                        // a failed transfer throws once, then later reads report inactive.
+                        if (failurePending.getAndSet(false)) {
+                            failureObserved.set(true)
+                            throw BackendException("media transfer failed")
+                        }
+                        return mediaSyncStatusResponse { active = false }
+                    }
+                }
+            }
+            Prefs.hkey = "test"
+            val work =
+                mockk<WorkInfo> {
+                    every { state } returns WorkInfo.State.RUNNING
+                    every { constraints } returns Constraints.NONE
+                }
+            val manager = mockk<WorkManager>(relaxed = true)
+            every { manager.getWorkInfosForUniqueWorkFlow(UniqueWorkNames.SYNC_MEDIA) } returns MutableStateFlow(listOf(work))
+            mockkObject(WorkManager.Companion)
+            every { WorkManager.getInstance(any()) } returns manager
+            val worker =
+                TestListenableWorkerBuilder<SyncMediaWorker>(
+                    targetContext,
+                    SyncAuth(syncAuth { hkey = "test" }).toWorkData(),
+                ).build()
+            val result = async { worker.doWork() }
+            runCurrent() // syncMedia has returned; the worker is in its notification delay.
+
+            launchFragment { MediaSyncBottomSheet() }.use {
+                advanceRobolectricLooperUntil { failureObserved.get() }
+            }
+
+            assertEquals(Result.failure(), result.await(), "the sheet must preserve the transfer failure for the worker")
+            assertTrue(lastMediaSyncAttempt!!.failed, "the last attempt must be shown as incomplete")
+        }
+}

Media sync shows a stale connection status

Subject: Media sync shows a stale connection status

Connecting to mobile data while unmetered work remains queued leaves the sheet showing its previous offline status.

---
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncConnectivityRegressionTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncConnectivityRegressionTest.kt
new file mode 100644
index 0000000000..05c97593b2
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncConnectivityRegressionTest.kt
@@ -0,0 +1,75 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.sync
+
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.net.NetworkInfo
+import androidx.core.content.getSystemService
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.work.NetworkType
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import anki.sync.syncAuth
+import com.ichi2.anki.CommonString
+import com.ichi2.anki.RobolectricTest
+import com.ichi2.anki.settings.Prefs
+import com.ichi2.anki.worker.SyncMediaWorker
+import com.ichi2.anki.worker.UniqueWorkNames
+import com.ichi2.testutils.launchFragment
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.runCurrent
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.shadows.ShadowNetworkCapabilities
+import org.robolectric.shadows.ShadowNetworkInfo
+import kotlin.test.assertEquals
+import kotlin.time.Duration.Companion.seconds
+
+@RunWith(AndroidJUnit4::class)
+class MediaSyncConnectivityRegressionTest : RobolectricTest() {
+    @Test
+    @Suppress("DEPRECATION")
+    fun `queued media status updates when an offline device connects to mobile data`() =
+        runTest {
+            Prefs.hkey = "test"
+            val connectivity = targetContext.getSystemService<ConnectivityManager>()!!
+            val shadowConnectivity = shadowOf(connectivity)
+            shadowConnectivity.setActiveNetworkInfo(null)
+            shadowConnectivity.setNetworkCallbacksEnabled(true)
+            SyncMediaWorker.start(targetContext, SyncAuth(syncAuth { hkey = "test" }), NetworkType.UNMETERED)
+
+            launchFragment { MediaSyncBottomSheet() }.use { scenario ->
+                scenario.onFragment { sheet ->
+                    val waitingForConnection = getResourceString(CommonString.media_sync_waiting_for_network_title)
+                    advanceRobolectricLooperUntil { sheet.binding.title.text.toString() == waitingForConnection }
+
+                    shadowConnectivity.setActiveNetworkInfo(
+                        ShadowNetworkInfo.newInstance(NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_MOBILE, 0, true, true),
+                    )
+                    val mobile = connectivity.activeNetwork!!
+                    val capabilities =
+                        ShadowNetworkCapabilities.newInstance().apply {
+                            shadowOf(this).addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+                            shadowOf(this).addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
+                            shadowOf(this).addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)
+                        }
+                    shadowConnectivity.setNetworkCapabilities(mobile, capabilities)
+                    shadowConnectivity.setDefaultNetworkActive(true)
+                    shadowConnectivity.networkCallbacks.toList().forEach { it.onCapabilitiesChanged(mobile, capabilities) }
+                    advanceTimeBy(2.seconds)
+                    runCurrent()
+                    advanceRobolectricLooper()
+
+                    val work = WorkManager.getInstance(targetContext).getWorkInfosForUniqueWork(UniqueWorkNames.SYNC_MEDIA).get().single()
+                    assertEquals(WorkInfo.State.ENQUEUED, work.state, "mobile data cannot satisfy the unmetered constraint")
+                    assertEquals(
+                        getResourceString(CommonString.media_sync_waiting_for_wifi_title),
+                        sheet.binding.title.text.toString(),
+                        "the displayed blocking condition must follow connectivity even when WorkInfo is unchanged",
+                    )
+                }
+            }
+        }
+}

Aborting media sync leaves a stale success status

Subject: Aborting media sync leaves a stale success status

If cancellation is recorded after the terminal work state, the open sheet continues showing the previous successful sync.

---
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncCancellationStatusRegressionTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncCancellationStatusRegressionTest.kt
new file mode 100644
index 0000000000..295bf73dcd
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncCancellationStatusRegressionTest.kt
@@ -0,0 +1,103 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.sync
+
+import androidx.core.view.isVisible
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.work.Constraints
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import androidx.work.testing.TestListenableWorkerBuilder
+import anki.sync.mediaSyncStatusResponse
+import anki.sync.syncAuth
+import com.ichi2.anki.CollectionManager
+import com.ichi2.anki.CommonString
+import com.ichi2.anki.RobolectricTest
+import com.ichi2.anki.settings.Prefs
+import com.ichi2.anki.worker.SyncMediaWorker
+import com.ichi2.anki.worker.UniqueWorkNames
+import com.ichi2.anki.worker.toWorkData
+import com.ichi2.testutils.launchFragment
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.unmockkObject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runCurrent
+import net.ankiweb.rsdroid.Backend
+import net.ankiweb.rsdroid.BackendFactory
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@RunWith(AndroidJUnit4::class)
+class MediaSyncCancellationStatusRegressionTest : RobolectricTest() {
+    @After
+    fun resetOverrides() {
+        BackendFactory.setOverride(null)
+        unmockkObject(WorkManager.Companion)
+    }
+
+    @Test
+    fun `abort refreshes the saved result even when cancellation is recorded after WorkInfo`() =
+        runTest {
+            CollectionManager.discardBackend()
+            BackendFactory.setOverride {
+                object : Backend() {
+                    override fun syncMedia(input: anki.sync.SyncAuth) = Unit
+
+                    override fun mediaSyncStatus() = mediaSyncStatusResponse { active = true }
+                }
+            }
+            Prefs.hkey = "test"
+            Prefs.recordMediaSync(1_700_000_000_000L, failed = false)
+            fun work(workState: WorkInfo.State) =
+                mockk<WorkInfo> {
+                    every { state } returns workState
+                    every { constraints } returns Constraints.NONE
+                }
+
+            val workInfos = MutableStateFlow(listOf(work(WorkInfo.State.RUNNING)))
+            val manager = mockk<WorkManager>(relaxed = true)
+            every { manager.getWorkInfosForUniqueWorkFlow(UniqueWorkNames.SYNC_MEDIA) } returns workInfos
+            every { manager.cancelUniqueWork(UniqueWorkNames.SYNC_MEDIA) } answers {
+                // WorkManager publishes CANCELLED before asynchronously cancelling the worker.
+                workInfos.value = listOf(work(WorkInfo.State.CANCELLED))
+                mockk(relaxed = true)
+            }
+            mockkObject(WorkManager.Companion)
+            every { WorkManager.getInstance(any()) } returns manager
+            val worker =
+                TestListenableWorkerBuilder<SyncMediaWorker>(
+                    targetContext,
+                    SyncAuth(syncAuth { hkey = "test" }).toWorkData(),
+                ).build()
+            val workerJob = launch { worker.doWork() }
+            runCurrent()
+
+            launchFragment { MediaSyncBottomSheet() }.use { scenario ->
+                scenario.onFragment { sheet ->
+                    advanceRobolectricLooperUntil { sheet.binding.abort.isVisible }
+                    sheet.binding.abort.performClick()
+                    runCurrent()
+                    advanceRobolectricLooperUntil {
+                        sheet.binding.title.text.toString() == getResourceString(CommonString.media_sync_up_to_date_title)
+                    }
+
+                    workerJob.cancel()
+                    runCurrent()
+                    advanceRobolectricLooper()
+                    runCurrent()
+                    assertTrue(lastMediaSyncAttempt!!.failed, "the real worker cancellation handler must have recorded failure")
+                    assertEquals(
+                        getResourceString(CommonString.media_sync_incomplete_title),
+                        sheet.binding.title.text.toString(),
+                        "recording cancellation after the terminal work state must refresh the open sheet",
+                    )
+                }
+            }
+        }
+}

Media sync history leaks between accounts

Subject: Media sync history leaks between accounts

After an account switch, the sheet can display the previous accountỲÄôs completed transfer, including when an old worker finishes later.

---
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncAccountHistoryRegressionTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncAccountHistoryRegressionTest.kt
new file mode 100644
index 0000000000..9df2be3c74
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncAccountHistoryRegressionTest.kt
@@ -0,0 +1,100 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.sync
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.work.ListenableWorker.Result
+import androidx.work.testing.TestListenableWorkerBuilder
+import anki.sync.syncAuth
+import com.ichi2.anki.CollectionManager
+import com.ichi2.anki.CommonString
+import com.ichi2.anki.RobolectricTest
+import com.ichi2.anki.account.LoggedInViewModel
+import com.ichi2.anki.settings.Prefs
+import com.ichi2.anki.updateLogin
+import com.ichi2.anki.worker.SyncMediaWorker
+import com.ichi2.anki.worker.toWorkData
+import com.ichi2.testutils.launchFragment
+import kotlinx.coroutines.async
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runCurrent
+import net.ankiweb.rsdroid.Backend
+import net.ankiweb.rsdroid.BackendFactory
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+@RunWith(AndroidJUnit4::class)
+class MediaSyncAccountHistoryRegressionTest : RobolectricTest() {
+    override fun getCollectionStorageMode() = CollectionStorageMode.ON_DISK
+
+    @After
+    fun resetBackendFactory() {
+        BackendFactory.setOverride(null)
+    }
+
+    @Test
+    fun `logging into another account without syncing must not reuse the previous success`() =
+        runTest {
+            Prefs.hkey = "account-a"
+            Prefs.username = "Account A"
+            Prefs.recordMediaSync(1_700_000_000_000L, failed = false)
+
+            LoggedInViewModel().onLogout()
+            advanceUntilIdle()
+            assertNull(Prefs.hkey, "the production logout path must have finished")
+
+            assertNoResultForNewAccount()
+        }
+
+    @Test
+    fun `authentication-driven logout also invalidates another account's success`() {
+        Prefs.hkey = "account-a"
+        Prefs.recordMediaSync(1_700_000_000_000L, failed = false)
+        // Collection authentication failure clears credentials through this production helper.
+        updateLogin("", "")
+
+        assertNoResultForNewAccount()
+    }
+
+    @Test
+    fun `a worker finishing after the account changes must not attribute its result to the new account`() =
+        runTest {
+            CollectionManager.discardBackend()
+            BackendFactory.setOverride {
+                object : Backend() {
+                    override fun syncMedia(input: anki.sync.SyncAuth) = Unit
+                }
+            }
+            Prefs.hkey = "account-a"
+            val worker =
+                TestListenableWorkerBuilder<SyncMediaWorker>(
+                    targetContext,
+                    SyncAuth(syncAuth { hkey = "account-a" }).toWorkData(),
+                ).build()
+            val result = async { worker.doWork() }
+            runCurrent() // The worker has started with account A and is in its notification delay.
+            updateLogin("Account B", "account-b")
+            assertEquals(Result.success(), result.await())
+
+            assertNoResultForNewAccount()
+        }
+
+    private fun assertNoResultForNewAccount() {
+        // Login's Continue action permits using the new credentials without a sync.
+        Prefs.hkey = "account-b"
+        Prefs.username = "Account B"
+        launchFragment { MediaSyncBottomSheet() }.use { scenario ->
+            scenario.onFragment { sheet ->
+                advanceRobolectricLooperUntil { sheet.binding.summary.length() > 0 }
+                assertEquals(
+                    getResourceString(CommonString.media_sync_not_recorded_title),
+                    sheet.binding.title.text.toString(),
+                    "account A's completed transfer must not be presented as account B's result",
+                )
+            }
+        }
+    }
+}

Large text makes Abort unreachable in compact windows

Subject: Large text makes Abort unreachable in compact windows

At enlarged text sizes in a short window, the sheetỲÄôs non-scrollable layout leaves Abort without a touch target.

---
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncCompactLayoutRegressionTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncCompactLayoutRegressionTest.kt
new file mode 100644
index 0000000000..06c634b44c
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncCompactLayoutRegressionTest.kt
@@ -0,0 +1,100 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.sync
+
+import android.graphics.Rect
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.isVisible
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.work.Constraints
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import anki.sync.mediaSyncProgress
+import anki.sync.mediaSyncStatusResponse
+import com.ichi2.anki.CollectionManager
+import com.ichi2.anki.RobolectricTest
+import com.ichi2.anki.settings.Prefs
+import com.ichi2.anki.worker.UniqueWorkNames
+import com.ichi2.testutils.launchFragment
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.unmockkObject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.runBlocking
+import net.ankiweb.rsdroid.Backend
+import net.ankiweb.rsdroid.BackendFactory
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@RunWith(AndroidJUnit4::class)
+@Config(qualifiers = "w640dp-h300dp-land")
+class MediaSyncCompactLayoutRegressionTest : RobolectricTest() {
+    @After
+    fun resetOverrides() {
+        BackendFactory.setOverride(null)
+        unmockkObject(WorkManager.Companion)
+    }
+
+    @Test
+    fun `abort remains reachable with large text in a short landscape window`() {
+        RuntimeEnvironment.setFontScale(2f)
+        runBlocking { CollectionManager.discardBackend() }
+        BackendFactory.setOverride {
+            object : Backend() {
+                override fun mediaSyncStatus() =
+                    mediaSyncStatusResponse {
+                        active = true
+                        progress =
+                            mediaSyncProgress {
+                                added = "Added: 1"
+                                removed = "Removed: 0"
+                                checked = "Checked: 2"
+                            }
+                    }
+            }
+        }
+        Prefs.hkey = "test"
+        val work =
+            mockk<WorkInfo> {
+                every { state } returns WorkInfo.State.RUNNING
+                every { constraints } returns Constraints.NONE
+            }
+        val manager = mockk<WorkManager>(relaxed = true)
+        every { manager.getWorkInfosForUniqueWorkFlow(UniqueWorkNames.SYNC_MEDIA) } returns MutableStateFlow(listOf(work))
+        mockkObject(WorkManager.Companion)
+        every { WorkManager.getInstance(any()) } returns manager
+
+        launchFragment { MediaSyncBottomSheet() }.use { scenario ->
+            scenario.onFragment { sheet ->
+                advanceRobolectricLooperUntil { sheet.binding.progress.isVisible }
+                val density = sheet.resources.displayMetrics.density
+                val width = (640 * density).toInt()
+                val height = (300 * density).toInt()
+                val root = sheet.requireView()
+                (root.parent as? ViewGroup)?.removeView(root)
+                root.measure(
+                    View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
+                    View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY),
+                )
+                root.layout(0, 0, width, height)
+                val abort = sheet.binding.abort
+                assertTrue(
+                    abort.height > 0,
+                    "Abort needs a touch target at 200% text in a 300dp window; root=${root.height}px, abort=${abort.height}px",
+                )
+                abort.requestRectangleOnScreen(Rect(0, 0, abort.width, abort.height), true)
+                advanceRobolectricLooper()
+                val visible = Rect()
+                assertTrue(abort.getGlobalVisibleRect(visible), "Abort must be reachable by scrolling into view")
+                assertEquals(abort.height, visible.height(), "Abort must fit fully in the visible viewport after scrolling")
+            }
+        }
+    }
+}

Unreproduced candidate: queued media work can retain the signed-out accoun's credentials; equivalent scheduling also exists before this change.

Validation limits: local unit and bounded-view checks use controlled backend, authentication, and work states; no emulator or live AnkiWeb validation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Needs Author Reply Waiting for a reply from the original author Strings

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants