0
0
mirror of https://github.com/ankidroid/Anki-Android.git synced 2024-09-20 03:52:15 +02:00

feat: show that media is running in-app (#16127)

* test: remove RobolectricTestAnnotationTest

We're going to introduce WorkManagerTestInitHelper and it's
outlived its usefulness

Change to a check on `setUp`

* feat: show that media is running in-app

useful if the user has notifications disabled

* fix: sync badges

---------

Co-authored-by: David Allison <62114487+david-allison@users.noreply.github.com>
This commit is contained in:
Brayan Oliveira 2024-04-21 10:19:43 -03:00 committed by GitHub
parent 340f2fa9ba
commit f502ddb852
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 265 additions and 80 deletions

View File

@ -403,6 +403,7 @@ dependencies {
testImplementation(libs.androidx.espresso.contrib) {
exclude module: "protobuf-lite"
}
testImplementation libs.androidx.work.testing
androidTestImplementation project(':testlib')

View File

@ -601,7 +601,16 @@
>
<meta-data android:name="com.ichi2.anki.provider.spec" android:value="2" />
</provider>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="remove">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.apkgfileprovider"

View File

@ -33,6 +33,7 @@ import androidx.annotation.VisibleForTesting
import androidx.core.content.edit
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.MutableLiveData
import androidx.work.Configuration
import com.ichi2.anki.CrashReportService.sendExceptionReport
import com.ichi2.anki.analytics.UsageAnalytics
import com.ichi2.anki.browser.SharedPreferencesLastDeckIdRepository
@ -69,7 +70,7 @@ import java.util.Locale
*/
@KotlinCleanup("lots to do")
@KotlinCleanup("IDE Lint")
open class AnkiDroidApp : Application() {
open class AnkiDroidApp : Application(), Configuration.Provider {
/** An exception if the WebView subsystem fails to load */
private var webViewError: Throwable? = null
private val notifications = MutableLiveData<Void?>()
@ -80,6 +81,9 @@ open class AnkiDroidApp : Application() {
/** Used to avoid showing extra progress dialogs when one already shown. */
var progressDialogShown = false
override val workManagerConfiguration: Configuration
get() = Configuration.Builder().build()
@KotlinCleanup("analytics can be moved to attachBaseContext()")
/**
* On application creation.

View File

@ -53,6 +53,8 @@ import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.os.bundleOf
import androidx.core.text.parseAsHtml
import androidx.core.view.MenuItemCompat
import androidx.core.view.isVisible
import androidx.fragment.app.commit
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
@ -61,10 +63,13 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import androidx.work.WorkInfo
import androidx.work.WorkManager
import anki.collection.OpChanges
import anki.sync.SyncStatusResponse
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.progressindicator.CircularProgressIndicator
import com.google.android.material.progressindicator.LinearProgressIndicator
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar
import com.ichi2.anim.ActivityTransitionAnimation.Direction.*
@ -116,6 +121,7 @@ import com.ichi2.anki.utils.SECONDS_PER_DAY
import com.ichi2.anki.widgets.DeckAdapter
import com.ichi2.anki.worker.SyncMediaWorker
import com.ichi2.anki.worker.SyncWorker
import com.ichi2.anki.worker.UniqueWorkNames
import com.ichi2.annotations.NeedsTest
import com.ichi2.async.*
import com.ichi2.compat.CompatHelper.Companion.getSerializableCompat
@ -129,6 +135,7 @@ import com.ichi2.utils.*
import com.ichi2.utils.NetworkUtils.isActiveNetworkMetered
import com.ichi2.widget.WidgetStatus
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import makeLinksClickable
@ -230,6 +237,8 @@ open class DeckPicker :
addCallback(activeSnackbarCallback)
}
private var syncMediaProgressJob: Job? = null
// flag keeping track of when the app has been paused
var activityPaused = false
private set
@ -791,6 +800,7 @@ open class DeckPicker :
floatingActionMenu.closeFloatingActionMenu(applyRiseAndShrinkAnimation = false)
menuInflater.inflate(R.menu.deck_picker, menu)
menu.findItem(R.id.action_export)?.title = TR.exportingExport()
setupMediaSyncMenuItem(menu)
setupSearchIcon(menu.findItem(R.id.deck_picker_action_filter))
toolbarSearchView = menu.findItem(R.id.deck_picker_action_filter).actionView as SearchView
toolbarSearchView?.maxWidth = Integer.MAX_VALUE
@ -810,6 +820,31 @@ open class DeckPicker :
private var migrationProgressPublishingJob: Job? = null
private var cachedMigrationProgressMenuItemActionView: View? = null
private fun setupMediaSyncMenuItem(menu: Menu) {
// shouldn't be necessary, but `invalidateOptionsMenu()` is called way more than necessary
syncMediaProgressJob?.cancel()
val syncItem = menu.findItem(R.id.action_sync)
val progressIndicator = syncItem.actionView
?.findViewById<LinearProgressIndicator>(R.id.progress_indicator)
val workManager = WorkManager.getInstance(this)
val flow = workManager.getWorkInfosForUniqueWorkFlow(UniqueWorkNames.SYNC_MEDIA)
syncMediaProgressJob = lifecycleScope.launch {
flow.flowWithLifecycle(lifecycle).collectLatest {
val workInfo = it.lastOrNull()
if (workInfo?.state == WorkInfo.State.RUNNING && progressIndicator?.isVisible == false) {
Timber.i("DeckPicker: Showing media sync progress indicator")
progressIndicator.isVisible = true
} else if (progressIndicator?.isVisible == true) {
Timber.i("DeckPicker: Hiding media sync progress indicator")
progressIndicator.isVisible = false
}
}
}
}
/**
* Set up the menu item that shows circular progress of storage migration.
* Can be called multiple times without harm.
@ -956,32 +991,32 @@ open class DeckPicker :
private fun updateSyncIconFromState(menuItem: MenuItem, state: OptionsMenuState) {
if (state.mediaMigrationState is MediaMigrationState.Ongoing) {
menuItem.isVisible = false
} else {
menuItem.isVisible = true
menuItem.setTitle(
when (state.syncIcon) {
SyncIconState.Normal, SyncIconState.PendingChanges -> R.string.button_sync
SyncIconState.OneWay -> R.string.sync_menu_title_one_way_sync
SyncIconState.NotLoggedIn -> R.string.sync_menu_title_no_account
}
)
return
}
menuItem.isVisible = true
menuItem.setTitle(
when (state.syncIcon) {
SyncIconState.Normal -> {
BadgeDrawableBuilder.removeBadge(menuItem)
}
SyncIconState.PendingChanges -> {
BadgeDrawableBuilder(this)
.withColor(getColor(R.color.badge_warning))
.replaceBadge(menuItem)
}
SyncIconState.OneWay, SyncIconState.NotLoggedIn -> {
BadgeDrawableBuilder(this)
.withText('!')
.withColor(getColor(R.color.badge_error))
.replaceBadge(menuItem)
}
SyncIconState.Normal, SyncIconState.PendingChanges -> R.string.button_sync
SyncIconState.OneWay -> R.string.sync_menu_title_one_way_sync
SyncIconState.NotLoggedIn -> R.string.sync_menu_title_no_account
}
)
val provider = MenuItemCompat.getActionProvider(menuItem) as? SyncActionProvider ?: return
when (state.syncIcon) {
SyncIconState.Normal -> {
BadgeDrawableBuilder.removeBadge(provider)
}
SyncIconState.PendingChanges -> {
BadgeDrawableBuilder(this)
.withColor(getColor(R.color.badge_warning))
.replaceBadge(provider)
}
SyncIconState.OneWay, SyncIconState.NotLoggedIn -> {
BadgeDrawableBuilder(this)
.withText('!')
.withColor(getColor(R.color.badge_error))
.replaceBadge(provider)
}
}
}
@ -1040,7 +1075,14 @@ open class DeckPicker :
}
R.id.action_sync -> {
Timber.i("DeckPicker:: Sync button pressed")
sync()
val actionProvider = MenuItemCompat.getActionProvider(item) as? SyncActionProvider
if (actionProvider?.isProgressShown == true) {
launchCatchingTask {
monitorMediaSync(this@DeckPicker)
}
} else {
sync()
}
return true
}
R.id.action_scoped_storage_migrate -> {

View File

@ -22,10 +22,12 @@ import android.content.SharedPreferences
import android.content.res.Resources
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.core.content.edit
import anki.sync.SyncAuth
import anki.sync.SyncCollectionResponse
import anki.sync.syncAuth
import com.google.android.material.snackbar.Snackbar
import com.ichi2.anki.CollectionManager.TR
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.dialogs.DialogHandlerMessage
@ -42,7 +44,15 @@ import com.ichi2.libanki.syncLogin
import com.ichi2.libanki.utils.TimeManager
import com.ichi2.preferences.VersatileTextWithASwitchPreference
import com.ichi2.utils.NetworkUtils
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.ankiweb.rsdroid.Backend
import net.ankiweb.rsdroid.exceptions.BackendInterruptedException
import net.ankiweb.rsdroid.exceptions.BackendSyncException
import timber.log.Timber
@ -342,6 +352,52 @@ fun DeckPicker.shouldFetchMedia(preferences: SharedPreferences): Boolean {
(shouldFetchMedia == onlyIfUnmetered && !NetworkUtils.isActiveNetworkMetered())
}
suspend fun monitorMediaSync(
deckPicker: DeckPicker
) {
val backend = CollectionManager.getBackend()
val scope = CoroutineScope(Dispatchers.IO)
val dialog = withContext(Dispatchers.Main) {
AlertDialog.Builder(deckPicker)
.setTitle(TR.syncMediaLogTitle())
.setMessage("")
.setPositiveButton(R.string.dialog_continue) { _, _ ->
scope.cancel()
}
.setNegativeButton(R.string.dialog_cancel) { _, _ ->
cancelMediaSync(backend)
}
.show()
}
fun showMessage(msg: String) = deckPicker.showSnackbar(msg, Snackbar.LENGTH_SHORT)
scope.launch {
try {
while (true) {
// this will throw if the sync exited with an error
val resp = backend.mediaSyncStatus()
if (!resp.active) {
break
}
val text = resp.progress.run { "$added\n$removed\n$checked" }
dialog.setMessage(text)
delay(100)
}
showMessage(TR.syncMediaComplete())
} catch (_: BackendInterruptedException) {
showMessage(TR.syncMediaAborted())
} catch (_: CancellationException) {
// do nothing
} catch (_: Exception) {
showMessage(TR.syncMediaFailed())
} finally {
dialog.dismiss()
}
}
}
/**
* Called from [DeckPicker.onMediaSyncCompleted] -> [DeckPicker.migrate] if the app is backgrounded
*/

View File

@ -0,0 +1,60 @@
/*
* Copyright (c) 2024 Brayan Oliveira <brayandso.dev@gmail.com>
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.ichi2.anki
import android.app.Activity
import android.content.Context
import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.AppCompatImageButton
import androidx.core.view.isVisible
import com.google.android.material.progressindicator.LinearProgressIndicator
import com.ichi2.ui.RtlCompliantActionProvider.Companion.unwrapContext
class SyncActionProvider(context: Context) : ActionProviderCompat(context) {
val activity: Activity = unwrapContext(context)
private var progressIndicator: LinearProgressIndicator? = null
private var syncButton: AppCompatImageButton? = null
val isProgressShown: Boolean
get() = progressIndicator?.isVisible == true
var icon: Drawable?
get() = syncButton?.drawable
set(value) {
syncButton?.setImageDrawable(value)
}
override fun onCreateActionView(forItem: MenuItem): View {
val inflater = LayoutInflater.from(context)
val view = inflater.inflate(R.layout.sync_progress_layout, null)
progressIndicator = view.findViewById(R.id.progress_indicator)
syncButton = view.findViewById<AppCompatImageButton?>(R.id.button).apply {
setOnClickListener {
if (!forItem.isEnabled) {
return@setOnClickListener
}
activity.onOptionsItemSelected(forItem)
}
}
return view
}
}

View File

@ -18,9 +18,9 @@ package com.ichi2.ui
import android.content.Context
import android.graphics.drawable.Drawable
import android.view.MenuItem
import androidx.core.content.ContextCompat
import com.ichi2.anki.R
import com.ichi2.anki.SyncActionProvider
import timber.log.Timber
class BadgeDrawableBuilder(private val context: Context) {
@ -36,9 +36,9 @@ class BadgeDrawableBuilder(private val context: Context) {
return this
}
fun replaceBadge(menuItem: MenuItem) {
fun replaceBadge(provider: SyncActionProvider) {
Timber.d("Adding badge")
var originalIcon = menuItem.icon
var originalIcon = provider.icon
if (originalIcon is BadgeDrawable) {
originalIcon = originalIcon.current
}
@ -55,15 +55,15 @@ class BadgeDrawableBuilder(private val context: Context) {
val mutableDrawable = badgeDrawable.mutate()
mutableDrawable.setTint(color!!)
badge.setBadgeDrawable(mutableDrawable)
menuItem.icon = badge
provider.icon = badge
}
}
companion object {
fun removeBadge(menuItem: MenuItem) {
val icon = menuItem.icon
fun removeBadge(provider: SyncActionProvider) {
val icon = provider.icon
if (icon is BadgeDrawable) {
menuItem.icon = icon.drawable
provider.icon = icon.drawable
Timber.d("Badge removed")
}
}

View File

@ -60,7 +60,7 @@ class RtlCompliantActionProvider(context: Context) : ActionProviderCompat(contex
* @param context a context that may be of type [ContextWrapper]
* @return The activity of the passed context
*/
private fun unwrapContext(context: Context): Activity {
fun unwrapContext(context: Context): Activity {
var unwrappedContext: Context? = context
while (unwrappedContext !is Activity && unwrappedContext is ContextWrapper) {
unwrappedContext = unwrappedContext.baseContext

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:context=".SyncActionProvider">
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/button"
android:layout_width="@dimen/touch_target"
android:layout_height="@dimen/touch_target"
app:srcCompat="@drawable/ic_sync"
style="@android:style/Widget.ActionButton"
android:tint="@color/white"
tools:tint="?attr/colorControlNormal"
/>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress_indicator"
android:layout_width="28dp"
android:layout_height="wrap_content"
android:indeterminate="true"
android:layout_gravity="center|bottom"
android:layout_marginBottom="2dp"
app:indicatorColor="@color/slider_active_track_color"
app:trackColor="@color/slider_inactive_track_color"
android:visibility="gone"
tools:visibility="visible"
/>
</FrameLayout>

View File

@ -28,6 +28,7 @@
android:id="@+id/action_sync"
android:icon="@drawable/ic_sync"
android:title="@string/button_sync"
ankidroid:actionProviderClass="com.ichi2.anki.SyncActionProvider"
ankidroid:showAsAction="always"/>
<item
android:id="@+id/action_undo"

View File

@ -30,6 +30,9 @@ import androidx.fragment.app.DialogFragment
import androidx.sqlite.db.SupportSQLiteOpenHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.core.app.ApplicationProvider
import androidx.work.Configuration
import androidx.work.testing.SynchronousExecutor
import androidx.work.testing.WorkManagerTestInitHelper
import com.ichi2.anki.dialogs.DialogHandler
import com.ichi2.anki.dialogs.utils.FragmentTestActivity
import com.ichi2.anki.preferences.sharedPrefs
@ -94,6 +97,14 @@ open class RobolectricTest : AndroidTest {
ChangeManager.clearSubscribers()
validateRunWithAnnotationPresent()
val config = Configuration.Builder()
.setExecutor(SynchronousExecutor())
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(targetContext, config)
// resolved issues with the collection being reused if useInMemoryDatabase is false
CollectionManager.setColForTests(null)
@ -275,17 +286,7 @@ open class RobolectricTest : AndroidTest {
}
val targetContext: Context
get() {
return try {
ApplicationProvider.getApplicationContext()
} catch (e: IllegalStateException) {
if (e.message != null && e.message!!.startsWith("No instrumentation registered!")) {
// Explicitly ignore the inner exception - generates line noise
throw IllegalStateException("Annotate class: '${javaClass.simpleName}' with '@RunWith(AndroidJUnit4.class)'")
}
throw e
}
}
get() = ApplicationProvider.getApplicationContext()
/**
* Returns an instance of [SharedPreferences] using the test context
@ -422,6 +423,18 @@ open class RobolectricTest : AndroidTest {
fun editPreferences(action: SharedPreferences.Editor.() -> Unit) =
getPreferences().edit(action = action)
private fun validateRunWithAnnotationPresent() {
try {
ApplicationProvider.getApplicationContext()
} catch (e: IllegalStateException) {
if (e.message != null && e.message!!.startsWith("No instrumentation registered!")) {
// Explicitly ignore the inner exception - generates line noise
throw IllegalStateException("Annotate class: '${javaClass.simpleName}' with '@RunWith(AndroidJUnit4.class)'")
}
throw e
}
}
private fun maybeSetupBackend() {
try {
targetContext

View File

@ -1,34 +0,0 @@
/*
* Copyright (c) 2021 David Allison <davidallisongithub@gmail.com>
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.ichi2.anki
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.core.StringContains.containsString
import org.junit.Test
import kotlin.test.assertFailsWith
// explicitly missing @RunWith(AndroidJUnit4.class)
class RobolectricTestAnnotationTest : RobolectricTest() {
@Test
fun readableErrorIfNotAnnotated() {
val exception = assertFailsWith<IllegalStateException> {
@Suppress("UNUSED_VARIABLE")
val unused = this.targetContext
}
assertThat(exception.message, containsString("RobolectricTestAnnotationTest"))
assertThat(exception.message, containsString("@RunWith(AndroidJUnit4.class)"))
}
}

View File

@ -136,6 +136,7 @@ androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "andro
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxRunner" }
androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidxTextCore" }
androidx-test-core = { module = "androidx.test:core", version.ref = "androidxTextCore" }
androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "workRuntimeKtx" }
commons-exec = { module = "org.apache.commons:commons-exec", version.ref = "commonsExec" }
hamcrest = { module = "org.hamcrest:hamcrest", version.ref = "hamcrest" }
hamcrest-library = { module = "org.hamcrest:hamcrest-library", version.ref = "hamcrest" }