Repository navigation
feat(sync): show the media sync status in Sync settings - #22832
criticalAY wants to merge 1 commit into
Conversation
|
Important Maintainers: This PR contains Strings changes
|
|
Snapshot diff report vs
All 1 changed screenshotsMediaSyncBottomSheetScreenshotTest
|
|
There was a problem hiding this comment.
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
|
Tests are failing. I REALLY like the icons |
1933006 to
f6128af
Compare
|
maybe improve the sync dialog? make the button a menu? a bottom sheet dialog? |
|
Assisted-by: grok-4.7 Thank you — the status logic is a pure
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. |
|
Needs a plan so we can merge/close this. @BrayanDSO @criticalAY FWIW: I like the design |
|
I will make some changes then lets see but worth getting it in |
|
I like the idea. Not the place |
f6128af to
1fd6579
Compare
mikehardy
left a comment
There was a problem hiding this comment.
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.
1fd6579 to
a2d5046
Compare
|
@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
a2d5046 to
c3b68a5
Compare
mikehardy
left a comment
There was a problem hiding this comment.
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.
david-allison
left a comment
There was a problem hiding this comment.
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.
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:

didnt finish:

waiting for connection:

waiting for wifi:

not logged in:

Learning (optional, can help others)
NA
Checklist
Please, go through these checks before submitting the PR.