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

feat: upgrade from Volume gestures to Bindings

We remove the `VOLUME_UP` and `VOLUME_DOWN` gestures,
and all associated code

This removes the preferences:
"gestureVolumeUp"
"gestureVolumeDown"

Instead, this is passed to a binding, for example:

"binding_EDIT": [keycode 24]

Related: 6502
This commit is contained in:
David Allison 2021-08-26 00:31:01 +01:00 committed by Mike Hardy
parent 68493a0c63
commit dc6459f84b
9 changed files with 313 additions and 50 deletions

View File

@ -90,7 +90,6 @@ import com.afollestad.materialdialogs.MaterialDialog;
import com.drakeet.drawer.FullDraggableContainer;
import com.google.android.material.snackbar.Snackbar;
import com.ichi2.anim.ViewAnimation;
import com.ichi2.anki.cardviewer.Gesture;
import com.ichi2.anki.cardviewer.GestureProcessor;
import com.ichi2.anki.cardviewer.MissingImageHandler;
import com.ichi2.anki.cardviewer.OnRenderProcessGoneDelegate;
@ -347,10 +346,6 @@ public abstract class AbstractFlashcardViewer extends NavigationDrawerActivity i
/**
* Gesture Allocation
*/
@NonNull
private ViewerCommand mGestureVolumeUp = COMMAND_NOTHING;
@NonNull
private ViewerCommand mGestureVolumeDown = COMMAND_NOTHING;
protected final GestureProcessor mGestureProcessor = new GestureProcessor(this);
private String mCardContent;
@ -1531,33 +1526,6 @@ public abstract class AbstractFlashcardViewer extends NavigationDrawerActivity i
TaskManager.launchCollectionTask(new CollectionTask.AnswerAndGetCard(mCurrentCard, mCurrentEase), new AnswerCardHandler(true));
}
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
if (event.getAction() == KeyEvent.ACTION_DOWN) {
// assign correct gesture code
ViewerCommand gesture = COMMAND_NOTHING;
switch (event.getKeyCode()) {
case KeyEvent.KEYCODE_VOLUME_UP:
gesture = mGestureVolumeUp;
break;
case KeyEvent.KEYCODE_VOLUME_DOWN:
gesture = mGestureVolumeDown;
break;
}
// Execute gesture's command, but only consume event if action is assigned. We want the volume buttons to work normally otherwise.
if (gesture != COMMAND_NOTHING) {
executeCommand(gesture);
return true;
}
}
return super.dispatchKeyEvent(event);
}
// Set the content view to the one provided and initialize accessors.
protected void initLayout() {
FrameLayout cardContainer = findViewById(R.id.flashcard_frame);
@ -1947,8 +1915,6 @@ public abstract class AbstractFlashcardViewer extends NavigationDrawerActivity i
mLinkOverridesTouchGesture = preferences.getBoolean("linkOverridesTouchGesture", false);
if (mGesturesEnabled) {
mGestureProcessor.init(preferences);
mGestureVolumeUp = Gesture.VOLUME_UP.fromPreference(preferences);
mGestureVolumeDown = Gesture.VOLUME_DOWN.fromPreference(preferences);
}
if (preferences.getBoolean("keepScreenOn", false)) {

View File

@ -55,9 +55,7 @@ enum class Gesture(
TAP_RIGHT(R.string.gestures_tap_right, "gestureTapRight", ViewerCommand.COMMAND_FLIP_OR_ANSWER_RECOMMENDED),
TAP_BOTTOM_LEFT(R.string.gestures_corner_tap_bottom_left, "gestureTapBottomLeft", ViewerCommand.COMMAND_NOTHING),
TAP_BOTTOM(R.string.gestures_tap_bottom, "gestureTapBottom", ViewerCommand.COMMAND_FLIP_OR_ANSWER_EASE1),
TAP_BOTTOM_RIGHT(R.string.gestures_corner_tap_bottom_right, "gestureTapBottomRight", ViewerCommand.COMMAND_NOTHING),
VOLUME_UP(R.string.gestures_volume_up, "gestureVolumeUp", ViewerCommand.COMMAND_NOTHING),
VOLUME_DOWN(R.string.gestures_volume_down, "gestureVolumeDown", ViewerCommand.COMMAND_NOTHING);
TAP_BOTTOM_RIGHT(R.string.gestures_corner_tap_bottom_right, "gestureTapBottomRight", ViewerCommand.COMMAND_NOTHING);
fun fromPreference(prefs: SharedPreferences): ViewerCommand {
val value = prefs.getString(preferenceKey, null) ?: return preferenceDefault

View File

@ -16,6 +16,7 @@
package com.ichi2.anki.cardviewer;
import android.content.SharedPreferences;
import android.view.KeyEvent;
import com.ichi2.anki.R;
@ -27,6 +28,7 @@ import com.ichi2.anki.reviewer.MappableBinding;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.BiFunction;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
@ -112,6 +114,38 @@ public enum ViewerCommand {
.collect(Collectors.toList());
}
public void addBinding(SharedPreferences preferences, MappableBinding binding) {
BiFunction<List<MappableBinding>, MappableBinding, Boolean> addAtStart = (collection, element) -> {
// reorder the elements, moving the added binding to the first position
collection.remove(element);
collection.add(0, element);
return true;
};
addBindingInternal(preferences, binding, addAtStart);
}
public void addBindingAtEnd(SharedPreferences preferences, MappableBinding binding) {
BiFunction<List<MappableBinding>, MappableBinding, Boolean> addAtEnd = (collection, element) -> {
// do not reorder the elements
if (collection.contains(element)) {
return false;
}
collection.add(element);
return true;
};
addBindingInternal(preferences, binding, addAtEnd);
}
private void addBindingInternal(SharedPreferences preferences, MappableBinding binding, BiFunction<List<MappableBinding>, MappableBinding, Boolean> performAdd) {
if (this == COMMAND_NOTHING) {
return;
}
List<MappableBinding> bindings = MappableBinding.fromPreference(preferences, this);
performAdd.apply(bindings, binding);
String newValue = MappableBinding.Companion.toPreferenceString(bindings);
preferences.edit().putString(this.getPreferenceKey(), newValue).apply();
}
@NonNull
public List<MappableBinding> getDefaultValue() {
// If we use the serialised format, then this adds additional coupling to the properties.

View File

@ -17,10 +17,15 @@ package com.ichi2.anki.servicelayer
import android.content.Context
import android.content.SharedPreferences
import android.view.KeyEvent
import androidx.annotation.VisibleForTesting
import androidx.core.content.edit
import com.ichi2.anki.AnkiDroidApp
import com.ichi2.anki.cardviewer.ViewerCommand
import com.ichi2.anki.reviewer.Binding.Companion.keyCode
import com.ichi2.anki.reviewer.CardSide
import com.ichi2.anki.reviewer.FullScreenMode
import com.ichi2.anki.reviewer.MappableBinding
import timber.log.Timber
private typealias VersionIdentifier = Int
@ -69,6 +74,7 @@ object PreferenceUpgradeService {
/** Returns all instances of preference upgrade classes */
internal fun getAllInstances(legacyPreviousVersionCode: LegacyVersionIdentifier) = sequence<PreferenceUpgrade> {
yield(LegacyPreferenceUpgrade(legacyPreviousVersionCode))
yield(UpgradeVolumeButtonsToBindings())
}
/** Returns a list of preference upgrade classes which have not been applied */
@ -155,6 +161,52 @@ object PreferenceUpgradeService {
}
}
@VisibleForTesting
internal class UpgradeVolumeButtonsToBindings : PreferenceUpgrade(2) {
override fun upgrade(preferences: SharedPreferences) {
upgradeVolumeGestureToKeyBind(preferences, "gestureVolumeUp", KeyEvent.KEYCODE_VOLUME_UP)
upgradeVolumeGestureToKeyBind(preferences, "gestureVolumeDown", KeyEvent.KEYCODE_VOLUME_DOWN)
}
@VisibleForTesting
internal fun upgradeVolumeGestureToKeyBind(preferences: SharedPreferences, oldGesturePreferenceKey: String, volumeKeyCode: Int) {
Timber.d("Replacing gesture '%s' with binding", oldGesturePreferenceKey)
// This exists as a user may have mapped "volume down" to "UNDO".
// Undo already exists as a key binding, and we don't want to trash this during an upgrade
if (!preferences.contains(oldGesturePreferenceKey)) {
Timber.v("No preference to upgrade")
return
}
try {
replaceGestureWithBinding(preferences, oldGesturePreferenceKey, volumeKeyCode)
} finally {
Timber.v("removing pref key: '%s'", oldGesturePreferenceKey)
// remove the old key
preferences.edit { remove(oldGesturePreferenceKey) }
}
}
private fun replaceGestureWithBinding(preferences: SharedPreferences, oldGesturePreferenceKey: String, volumeKeyCode: Int) {
// the preference should be set, but if it's null, then we have nothing to do
val gesture = preferences.getString(oldGesturePreferenceKey, "0") ?: return
// If the preference doesn't map (for example: it was removed), then nothing to do
val asInt = gesture.toIntOrNull() ?: return
val command = ViewerCommand.fromInt(asInt) ?: return
if (command == ViewerCommand.COMMAND_NOTHING) {
return
}
Timber.i("Moving preference from '%s' to '%s'", oldGesturePreferenceKey, command.preferenceKey)
// add to the binding_COMMANDNAME preference
val binding = MappableBinding(keyCode(volumeKeyCode), MappableBinding.Screen.Reviewer(CardSide.BOTH))
command.addBindingAtEnd(preferences, binding)
}
}
fun performUpgrade(preferences: SharedPreferences) {
Timber.i("Running preference upgrade: ${this.javaClass.simpleName}")
upgrade(preferences)

View File

@ -115,11 +115,6 @@ class ControlPreference : ListPreference {
.onBindingChanged { binding -> checkExistingBinding(MappableBinding(binding, MappableBinding.Screen.Reviewer(CardSide.BOTH))) }
// select a side, then add
.onBindingSubmitted { binding ->
// for now, volume buttons are handled by the reviewer
if (isVolumeKey(binding)) {
UIUtils.showThemedToast(context, R.string.bindings_no_volume, true)
return@onBindingSubmitted
}
CardSideSelectionDialog.displayInstance(context) { side -> addBinding(MappableBinding(binding, MappableBinding.Screen.Reviewer(side))) }
}
.disallowModifierKeys()
@ -143,11 +138,7 @@ class ControlPreference : ListPreference {
}
/** Displays a warning to the user if the provided binding couldn't be used */
fun checkExistingBinding(binding: MappableBinding) {
if (isVolumeKey(binding.binding)) {
UIUtils.showThemedToast(context, R.string.bindings_no_volume, true)
return
}
private fun checkExistingBinding(binding: MappableBinding) {
val existingCommands = getExistingCommands(binding).toList()
if (existingCommands.isEmpty()) return // no conflicts
val commandNames = existingCommands.map { context.getString(it.resourceId) }

View File

@ -72,7 +72,7 @@ constructor(context: Context, attributeSet: AttributeSet? = null, defStyleAttr:
/** Lists all selectable gestures from this view (excludes null) */
fun availableValues(): List<Gesture> = Gesture.values().filter {
!VOLUME_GESTURES.contains(it) && (mTapGestureMode == TapGestureMode.NINE_POINT || !NINE_POINT_TAP_GESTURES.contains(it))
(mTapGestureMode == TapGestureMode.NINE_POINT || !NINE_POINT_TAP_GESTURES.contains(it))
}
/** Sets a callback which is called when the gesture is changed, and non-null */
@ -181,6 +181,5 @@ constructor(context: Context, attributeSet: AttributeSet? = null, defStyleAttr:
companion object {
val NINE_POINT_TAP_GESTURES = listOf(TAP_TOP_LEFT, TAP_TOP_RIGHT, TAP_CENTER, TAP_BOTTOM_LEFT, TAP_BOTTOM_RIGHT)
val VOLUME_GESTURES = listOf(VOLUME_UP, VOLUME_DOWN)
}
}

View File

@ -325,5 +325,4 @@ this formatter is used if the bind only applies to both the question and the ans
<string name="binding_remove_binding" comment="The parameter is the name of the key/gesture.
Keys cannot be translated yet.">Remove %s</string>
<string name="bindings_already_bound">Already bound to %s</string>
<string name="bindings_no_volume">Volume keys are supported in the Gestures menu</string>
</resources>

View File

@ -18,6 +18,7 @@ package com.ichi2.anki;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import com.afollestad.materialdialogs.DialogAction;
import com.afollestad.materialdialogs.MaterialDialog;
@ -271,6 +272,10 @@ public class RobolectricTest implements CollectionGetter {
}
protected SharedPreferences getPreferences() {
return AnkiDroidApp.getSharedPrefs(getTargetContext());
}
protected String getResourceString(int res) {
return getTargetContext().getString(res);

View File

@ -0,0 +1,219 @@
/*
* 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.servicemodel
import android.content.SharedPreferences
import androidx.core.content.edit
import com.ichi2.anki.RobolectricTest
import com.ichi2.anki.cardviewer.ViewerCommand
import com.ichi2.anki.reviewer.Binding.Companion.keyCode
import com.ichi2.anki.reviewer.CardSide
import com.ichi2.anki.reviewer.MappableBinding
import com.ichi2.anki.reviewer.MappableBinding.Screen.Reviewer
import com.ichi2.anki.servicelayer.PreferenceUpgradeService.PreferenceUpgrade.UpgradeVolumeButtonsToBindings
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.CoreMatchers.not
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers
import org.hamcrest.Matchers.empty
import org.hamcrest.Matchers.hasSize
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.ParameterizedRobolectricTestRunner
import timber.log.Timber
@RunWith(ParameterizedRobolectricTestRunner::class)
class UpgradeVolumeButtonsToBindingsTest(private val testData: TestData) : RobolectricTest() {
private val changedKeys = HashSet<String>()
private lateinit var prefs: SharedPreferences
private lateinit var instance: UpgradeVolumeButtonsToBindings
@Before
fun setup() {
super.setUp()
prefs = super.getPreferences()
instance = UpgradeVolumeButtonsToBindings()
prefs.registerOnSharedPreferenceChangeListener { _, key -> run { Timber.i("added key $key"); changedKeys.add(key) } }
}
@Test
fun test_preferences_not_opened_happy_path() {
// if the user has not opened the gestures, then nothing should be mapped
assertThat(prefs.contains(PREF_KEY_VOLUME_DOWN), equalTo(false))
assertThat(prefs.contains(PREF_KEY_VOLUME_UP), equalTo(false))
upgradeAllGestures()
// ensure that no settings were added to the preferences
assertThat(changedKeys, Matchers.contains("preferenceUpgradeVersion"))
}
@Test
fun test_preferences_opened_happy_path() {
// the default is that the user has not mapped the gesture, but has opened the screen
// so they are set to COMMAND_NOTHING
prefs.edit { putString(PREF_KEY_VOLUME_UP, ViewerCommand.COMMAND_NOTHING.toPreferenceString()) }
prefs.edit { putString(PREF_KEY_VOLUME_DOWN, ViewerCommand.COMMAND_NOTHING.toPreferenceString()) }
assertThat(prefs.contains(PREF_KEY_VOLUME_DOWN), equalTo(true))
assertThat(prefs.contains(PREF_KEY_VOLUME_UP), equalTo(true))
upgradeAllGestures()
// ensure that no settings were added to the preferences
assertThat(changedKeys, Matchers.contains("preferenceUpgradeVersion", PREF_KEY_VOLUME_DOWN, PREF_KEY_VOLUME_UP))
assertThat("Volume gestures are removed", prefs.contains(PREF_KEY_VOLUME_DOWN), equalTo(false))
assertThat("Volume gestures are removed", prefs.contains(PREF_KEY_VOLUME_UP), equalTo(false))
}
@Test
fun gesture_set_no_conflicts() {
// assume that we have a preference set, and that it has no defaults
val command = ViewerCommand.COMMAND_LOOKUP
prefs.edit { putString(testData.affectedPreferenceKey, command.toPreferenceString()) }
assertThat(prefs.contains(testData.affectedPreferenceKey), equalTo(true))
assertThat(prefs.contains(testData.unaffectedPreferenceKey), equalTo(false))
assertThat("example command should have no defaults", MappableBinding.fromPreference(prefs, command), empty())
upgradeAllGestures()
assertThat(changedKeys, Matchers.containsInAnyOrder("preferenceUpgradeVersion", testData.affectedPreferenceKey, command.preferenceKey))
assertThat("legacy preference removed", prefs.contains(testData.affectedPreferenceKey), equalTo(false))
assertThat("new preference added", prefs.contains(command.preferenceKey), equalTo(true))
val fromPreference = MappableBinding.fromPreference(prefs, command)
assertThat(fromPreference, hasSize(1))
val binding = fromPreference.first()
assertThat("should be a key binding", binding.isKey, equalTo(true))
assertThat("binding should match", binding, equalTo(MappableBinding(keyCode(testData.keyCode), Reviewer(CardSide.BOTH))))
}
@Test
fun if_mapped_to_non_empty_binding_then_added_to_end() {
// common path
// if the gesture was mapped to a command which already had bindings,
// check it is added to the list at the end
val command = ViewerCommand.COMMAND_EDIT
prefs.edit { putString(testData.affectedPreferenceKey, command.toPreferenceString()) }
assertThat(prefs.contains(testData.affectedPreferenceKey), equalTo(true))
assertThat(prefs.contains(testData.unaffectedPreferenceKey), equalTo(false))
assertThat("new preference does not exist", prefs.contains(command.preferenceKey), equalTo(false))
val previousCommands = MappableBinding.fromPreference(prefs, command)
assertThat("example command should have defaults", previousCommands, not(empty()))
upgradeAllGestures()
assertThat(changedKeys, Matchers.containsInAnyOrder("preferenceUpgradeVersion", testData.affectedPreferenceKey, command.preferenceKey))
assertThat("legacy preference removed", prefs.contains(testData.affectedPreferenceKey), equalTo(false))
assertThat("new preference exists", prefs.contains(command.preferenceKey), equalTo(true))
val currentCommands = MappableBinding.fromPreference(prefs, command)
assertThat("a binding was added to '${command.preferenceKey}'", currentCommands, hasSize(previousCommands.size + 1))
// ensure that the order was not changed - the last element is not included in the zip
previousCommands.zip(currentCommands).forEach {
assertThat("bindings should not change order", it.first, equalTo(it.second))
}
val addedBinding = currentCommands.last()
assertThat("last should be a key binding", addedBinding.isKey, equalTo(true))
assertThat("last binding should match", addedBinding, equalTo(testData.binding))
}
@Test
fun if_gesture_already_exists_then_do_not_modify_list() {
// the gestures shouldn't already be a keybind (as we've just introduced the feature)
// but if it is, then we want to ignore it in the upgrade.
val command = ViewerCommand.COMMAND_EDIT
command.addBinding(prefs, testData.binding)
prefs.edit { putString(testData.affectedPreferenceKey, command.toPreferenceString()) }
assertThat(prefs.contains(testData.affectedPreferenceKey), equalTo(true))
assertThat(prefs.contains(testData.unaffectedPreferenceKey), equalTo(false))
assertThat("new preference exists", prefs.contains(command.preferenceKey), equalTo(true))
val previousCommands = MappableBinding.fromPreference(prefs, command)
assertThat("example command should have defaults", previousCommands, hasSize(2))
assertThat(previousCommands.first(), equalTo(testData.binding))
upgradeAllGestures()
assertThat("Binding gestures should not be changed", changedKeys, Matchers.contains("preferenceUpgradeVersion", testData.affectedPreferenceKey))
assertThat("legacy preference removed", prefs.contains(testData.affectedPreferenceKey), equalTo(false))
assertThat("new preference still exists", prefs.contains(command.preferenceKey), equalTo(true))
}
@Test
fun invalid_preference_value_results_in_old_null_value_and_no_new_value() {
prefs.edit { putString(testData.affectedPreferenceKey, "bananas") }
upgradeAllGestures()
assertThat("Binding gestures should not be changed", changedKeys, Matchers.contains("preferenceUpgradeVersion", testData.affectedPreferenceKey))
assertThat("legacy preference removed", prefs.contains(testData.affectedPreferenceKey), equalTo(false))
}
@Test
fun invalid_command_value_results_in_old_null_value_and_no_new_value() {
// a valid int, but not a valid command
prefs.edit { putString(testData.affectedPreferenceKey, "-1") }
upgradeAllGestures()
assertThat("Binding gestures should not be changed", changedKeys, Matchers.containsInAnyOrder("preferenceUpgradeVersion", testData.affectedPreferenceKey))
assertThat("legacy preference removed", prefs.contains(testData.affectedPreferenceKey), equalTo(false))
}
private fun upgradeAllGestures() {
changedKeys.clear()
instance.performUpgrade(prefs)
}
companion object {
private const val KEYCODE_VOLUME_UP = 24
private const val KEYCODE_VOLUME_DOWN = 25
const val PREF_KEY_VOLUME_UP = "gestureVolumeUp"
const val PREF_KEY_VOLUME_DOWN = "gestureVolumeDown"
private val volume_up_binding = MappableBinding(keyCode(KEYCODE_VOLUME_UP), Reviewer(CardSide.BOTH))
private val volume_down_binding = MappableBinding(keyCode(KEYCODE_VOLUME_DOWN), Reviewer(CardSide.BOTH))
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{index}: isValid({0})={1}")
fun data(): Iterable<Array<Any>> {
// pref key, keyCode, opposite key
return arrayListOf<Array<Any>>(
arrayOf(TestData(PREF_KEY_VOLUME_UP, KEYCODE_VOLUME_UP, PREF_KEY_VOLUME_DOWN, volume_up_binding)),
arrayOf(TestData(PREF_KEY_VOLUME_DOWN, KEYCODE_VOLUME_DOWN, PREF_KEY_VOLUME_UP, volume_down_binding)),
).toList()
}
data class TestData(val affectedPreferenceKey: String, val keyCode: Int, val unaffectedPreferenceKey: String, val binding: MappableBinding)
}
}