diff --git a/.github/workflows/label.yml b/.github/workflows/label.yml index e63a7b53d60d..e02d05c4c48f 100644 --- a/.github/workflows/label.yml +++ b/.github/workflows/label.yml @@ -125,13 +125,12 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, - body: `Message to maintainers, this PR contains strings changes. - 1. Before merging this PR, it is best to run the "Sync Translations" GitHub action, then make and merge a PR from the i18n_sync branch to get translations cleaned out. - 2. Then merge this PR, and immediately do another translation PR so the huge change made by this PR's key changes are all by themselves. - - Read more about updating strings on the wiki, - - [localization-administration](https://github.com/ankidroid/Anki-Android/wiki/Development-Guide#localization-administration) - - [download-localized-strings](https://github.com/ankidroid/Anki-Android/wiki/Development-Guide#download-localized-strings)` + body: `> [!IMPORTANT] + > **Maintainers**: This PR contains https://github.com/ankidroid/Anki-Android/labels/Strings changes + + 1. [Sync Translations](https://github.com/ankidroid/Anki-Android/actions/workflows/sync_translations.yml) before merging this PR and wait for the action to complete + 2. Review and merge the [auto-generated PR](https://github.com/ankidroid/Anki-Android/pulls/mikehardy-machineaccount) in order to sync all user-submitted translations + 3. [Sync Translations again](https://github.com/ankidroid/Anki-Android/actions/workflows/sync_translations.yml) and merge the [PR](https://github.com/ankidroid/Anki-Android/pulls/mikehardy-machineaccount) so the huge automated string changes caused by merging this PR are by themselves and easy to review` }) } diff --git a/.github/workflows/opencollective_notices.yml b/.github/workflows/opencollective_notices.yml index 81ab184c3eaf..e3f9088f0849 100644 --- a/.github/workflows/opencollective_notices.yml +++ b/.github/workflows/opencollective_notices.yml @@ -91,7 +91,7 @@ jobs: // Incidentally you cannot use arry.forEach() with async functions; it does not await. for or for...in is required. for (const key of Object.keys(uniqueContributorsWithPR)) { // skip some users, we don't allow bots to claim for their work, yet? - if (key === 'github-actions' || key.includes('dependabot') || key.includes('[bot]')) { + if (key === 'github-actions' || key.includes('dependabot') || key.includes('[bot]' || key.includes('machineaccount')) { console.log(`ignoring pr with bot user ${key}`); continue; } diff --git a/.github/workflows/sync_translations.yml b/.github/workflows/sync_translations.yml index 5e87d8d709be..5dae1d71098c 100644 --- a/.github/workflows/sync_translations.yml +++ b/.github/workflows/sync_translations.yml @@ -3,20 +3,19 @@ name: Sync Translations on: workflow_dispatch: -permissions: - contents: read +# We use a machine account PAT from secrets so workflows are triggered +# the default token is not needed and should be fully restricted +permissions: {} jobs: sync_translations: - permissions: - contents: write # for Git to git push - pull-requests: write # to create the PR with changes name: 'Sync Translations with Crowdin' timeout-minutes: 20 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: + token: ${{ secrets.MACHINE_ACCOUNT_PAT }} ref: 'main' fetch-depth: 0 @@ -86,6 +85,7 @@ jobs: if: ${{ steps.tr_check.outputs.HAS_CHANGES }} uses: actions/github-script@v7 with: + github-token: ${{ secrets.MACHINE_ACCOUNT_PAT }} script: | const now = new Date(); // Date format used: YYYY/MM/DD HH:MM , UTC time(ex: 2023/01/13 08:38) diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle index 46fa7c125136..36590b4d05d4 100644 --- a/AnkiDroid/build.gradle +++ b/AnkiDroid/build.gradle @@ -85,8 +85,8 @@ android { // // This ensures the correct ordering between the various types of releases (dev < alpha < beta < release) which is // needed for upgrades to be offered correctly. - versionCode=21900202 - versionName="2.19beta2" + versionCode=21900204 + versionName="2.19beta4" minSdk libs.versions.minSdk.get().toInteger() // Stays until this is in a release: https://github.com/google/desugar_jdk_libs/commit/c01a5446ca13586b801dbba4d83c6821337b3cc2 diff --git a/AnkiDroid/proguard-rules.pro b/AnkiDroid/proguard-rules.pro index 8882b346beab..5fe1b04fd70d 100644 --- a/AnkiDroid/proguard-rules.pro +++ b/AnkiDroid/proguard-rules.pro @@ -16,6 +16,9 @@ # debugging stack traces. -keepattributes SourceFile,LineNumberTable +# We do not have commercial interests to protect, so optimize for easier debugging +-dontobfuscate + # Used through Reflection -keep class com.ichi2.anki.**.*Fragment { *; } -keep class * extends com.google.protobuf.GeneratedMessageLite { *; } diff --git a/AnkiDroid/robolectricDownloader.gradle b/AnkiDroid/robolectricDownloader.gradle index 87ee0d1f3976..cd7653444405 100644 --- a/AnkiDroid/robolectricDownloader.gradle +++ b/AnkiDroid/robolectricDownloader.gradle @@ -30,7 +30,7 @@ def robolectricAndroidSdkVersions = [ [androidVersion: "11", frameworkSdkBuildVersion: "6757853"], // [androidVersion: "12", frameworkSdkBuildVersion: "7732740"], // [androidVersion: "12.1", frameworkSdkBuildVersion: "8229987"], - [androidVersion: "13", frameworkSdkBuildVersion: "9030017"], +// [androidVersion: "13", frameworkSdkBuildVersion: "9030017"], [androidVersion: "14", frameworkSdkBuildVersion: "10818077"], ] @@ -42,7 +42,7 @@ tasks.register('robolectricSdkDownload') { // Generate the configuration and actual copy tasks. robolectricAndroidSdkVersions.forEach { robolectricSdkVersion -> - def version = "${robolectricSdkVersion['androidVersion']}-robolectric-${robolectricSdkVersion['frameworkSdkBuildVersion']}-i4" + def version = "${robolectricSdkVersion['androidVersion']}-robolectric-${robolectricSdkVersion['frameworkSdkBuildVersion']}-i6" // Creating a configuration with a dependency allows Gradle to manage the actual resolution of // the jar file diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt index 6cb2b0ac3129..663c0b8f1e9b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt @@ -23,11 +23,9 @@ package com.ichi2.anki import android.annotation.SuppressLint import android.annotation.TargetApi import android.content.ActivityNotFoundException -import android.content.BroadcastReceiver import android.content.ClipboardManager import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.content.SharedPreferences import android.content.res.Configuration import android.graphics.Bitmap @@ -72,7 +70,6 @@ import androidx.annotation.CheckResult import androidx.annotation.IdRes import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog -import androidx.core.content.ContextCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.children @@ -110,7 +107,6 @@ import com.ichi2.anki.pages.AnkiServer import com.ichi2.anki.pages.CongratsPage import com.ichi2.anki.pages.PostRequestHandler import com.ichi2.anki.preferences.sharedPrefs -import com.ichi2.anki.receiver.SdCardReceiver import com.ichi2.anki.reviewer.AutomaticAnswer import com.ichi2.anki.reviewer.AutomaticAnswer.AutomaticallyAnswered import com.ichi2.anki.reviewer.AutomaticAnswerAction @@ -129,7 +125,6 @@ import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.utils.OnlyOnce.Method.ANSWER_CARD import com.ichi2.anki.utils.OnlyOnce.preventSimultaneousExecutions import com.ichi2.annotations.NeedsTest -import com.ichi2.compat.CompatHelper.Companion.registerReceiverCompat import com.ichi2.compat.CompatHelper.Companion.resolveActivityCompat import com.ichi2.compat.ResolveInfoFlagsCompat import com.ichi2.libanki.Card @@ -192,10 +187,6 @@ abstract class AbstractFlashcardViewer : @VisibleForTesting val jsApi by lazy { AnkiDroidJsAPI(this) } - /** - * Broadcast that informs us when the sd card is about to be unmounted - */ - private var unmountReceiver: BroadcastReceiver? = null private var tagsDialogFactory: TagsDialogFactory? = null /** @@ -590,7 +581,7 @@ abstract class AbstractFlashcardViewer : super.onCollectionLoaded(col) val mediaDir = col.media.dir cardMediaPlayer = CardMediaPlayer.newInstance(this, getMediaBaseUrl(mediaDir)) - registerExternalStorageListener() + registerReceiver() restoreCollectionPreferences(col) initLayout() cardRenderContext = createInstance(this, col, typeAnswer!!) @@ -653,9 +644,6 @@ abstract class AbstractFlashcardViewer : override fun onDestroy() { super.onDestroy() tts.releaseTts(this) - if (unmountReceiver != null) { - unregisterReceiver(unmountReceiver) - } // WebView.destroy() should be called after the end of use // http://developer.android.com/reference/android/webkit/WebView.html#destroy() if (cardFrame != null) { @@ -805,24 +793,6 @@ abstract class AbstractFlashcardViewer : return cardContent != null } - /** - * Show/dismiss dialog when sd card is ejected/remounted (collection is saved by SdCardReceiver) - */ - private fun registerExternalStorageListener() { - if (unmountReceiver == null) { - unmountReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == SdCardReceiver.MEDIA_EJECT) { - finish() - } - } - } - val iFilter = IntentFilter() - iFilter.addAction(SdCardReceiver.MEDIA_EJECT) - registerReceiverCompat(unmountReceiver, iFilter, ContextCompat.RECEIVER_EXPORTED) - } - } - open fun undo(): Job { return launchCatchingTask { undoAndShowSnackbar(duration = Reviewer.ACTION_SNACKBAR_TIME) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt index 8213f74e496c..4b3630a8d734 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt @@ -6,16 +6,23 @@ package com.ichi2.anki import android.app.NotificationManager import android.app.PendingIntent import android.content.ActivityNotFoundException +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.graphics.BitmapFactory import android.graphics.Color import android.media.AudioManager import android.net.Uri import android.os.Build import android.os.Bundle +import android.view.KeyEvent +import android.view.KeyboardShortcutGroup +import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup +import android.view.Window import android.view.WindowManager import android.view.animation.Animation import android.widget.ProgressBar @@ -35,10 +42,12 @@ import androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT import androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_SYSTEM import androidx.core.app.NotificationCompat import androidx.core.app.PendingIntentCompat +import androidx.core.content.ContextCompat import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import com.google.android.material.color.MaterialColors +import com.google.android.material.snackbar.Snackbar import com.ichi2.anim.ActivityTransitionAnimation import com.ichi2.anim.ActivityTransitionAnimation.Direction import com.ichi2.anim.ActivityTransitionAnimation.Direction.DEFAULT @@ -51,9 +60,14 @@ import com.ichi2.anki.dialogs.SimpleMessageDialog.SimpleMessageDialogListener import com.ichi2.anki.preferences.Preferences import com.ichi2.anki.preferences.Preferences.Companion.MINIMUM_CARDS_DUE_FOR_NOTIFICATION import com.ichi2.anki.preferences.sharedPrefs +import com.ichi2.anki.receiver.SdCardReceiver import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.workarounds.AppLoadedFromBackupWorkaround.showedActivityFailedScreen import com.ichi2.async.CollectionLoader +import com.ichi2.compat.CompatHelper +import com.ichi2.compat.CompatHelper.Companion.registerReceiverCompat +import com.ichi2.compat.CompatV24 +import com.ichi2.compat.ShortcutGroupProvider import com.ichi2.compat.customtabs.CustomTabActivityHelper import com.ichi2.compat.customtabs.CustomTabsFallback import com.ichi2.compat.customtabs.CustomTabsHelper @@ -66,11 +80,20 @@ import androidx.browser.customtabs.CustomTabsIntent.Builder as CustomTabsIntentB @UiThread @KotlinCleanup("set activityName") -open class AnkiActivity : AppCompatActivity, SimpleMessageDialogListener { +open class AnkiActivity : AppCompatActivity, SimpleMessageDialogListener, ShortcutGroupProvider, AnkiActivityProvider { + + /** + * Receiver that informs us when a broadcast listen in [broadcastsActions] is received. + * + * @see registerReceiver + * @see broadcastsActions + */ + private var broadcastReceiver: BroadcastReceiver? = null /** The name of the parent class (example: 'Reviewer') */ private val activityName: String val dialogHandler = DialogHandler(this) + override val ankiActivity = this private val customTabActivityHelper: CustomTabActivityHelper = CustomTabActivityHelper() @@ -112,6 +135,11 @@ open class AnkiActivity : AppCompatActivity, SimpleMessageDialogListener { customTabActivityHelper.unbindCustomTabsService(this) } + override fun onDestroy() { + super.onDestroy() + broadcastReceiver?.let { unregisterReceiver(it) } + } + override fun onResume() { super.onResume() UsageAnalytics.sendAnalyticsScreenView(this) @@ -150,6 +178,40 @@ open class AnkiActivity : AppCompatActivity, SimpleMessageDialogListener { hideProgressBar() } + /** + * Maps from intent name action to function to run when this action is received by [broadcastReceiver]. + * By default it handles [SdCardReceiver.MEDIA_EJECT], and shows/dismisses dialogs when an SD + * card is ejected/remounted (collection is saved beforehand by [SdCardReceiver]) + */ + protected open val broadcastsActions = mapOf( + SdCardReceiver.MEDIA_EJECT to { onSdCardNotMounted() } + ) + + /** + * Register a broadcast receiver, associating an intent to an action as in [broadcastsActions]. + * Add more values in [broadcastsActions] to react to more intents. + */ + fun registerReceiver() { + if (broadcastReceiver != null) { + // Receiver already registered + return + } + broadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + broadcastsActions[intent.action]?.invoke() + } + }.also { + val iFilter = IntentFilter() + broadcastsActions.keys.map(iFilter::addAction) + registerReceiverCompat(it, iFilter, ContextCompat.RECEIVER_EXPORTED) + } + } + + protected fun onSdCardNotMounted() { + showThemedToast(this, resources.getString(R.string.sd_card_not_mounted), false) + finish() + } + /** Legacy code should migrate away from this, and use withCol {} instead. * */ val getColUnsafe: Collection @@ -570,6 +632,32 @@ open class AnkiActivity : AppCompatActivity, SimpleMessageDialogListener { finish() } + override fun onProvideKeyboardShortcuts( + data: MutableList, + menu: Menu?, + deviceId: Int + ) { + val shortcutGroups = CompatHelper.compat.getShortcuts(this) + data.addAll(shortcutGroups) + super.onProvideKeyboardShortcuts(data, menu, deviceId) + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { + if (event.isAltPressed && keyCode == KeyEvent.KEYCODE_K) { + CompatHelper.compat.showKeyboardShortcutsDialog(this) + return true + } + + val done = super.onKeyUp(keyCode, event) + + // Show snackbar only if the current activity have shortcuts, a modifier key is pressed and the keyCode is an unmapped alphabet key + if (!done && shortcuts != null && (event.isCtrlPressed || event.isAltPressed || event.isMetaPressed) && (keyCode in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z) || (keyCode in KeyEvent.KEYCODE_NUMPAD_0..KeyEvent.KEYCODE_NUMPAD_9)) { + showSnackbar(R.string.show_shortcuts_message, Snackbar.LENGTH_SHORT) + return true + } + return false + } + /** * If storage permissions are not granted, shows a toast message and finishes the activity. * @@ -587,6 +675,9 @@ open class AnkiActivity : AppCompatActivity, SimpleMessageDialogListener { return false } + override val shortcuts + get(): CompatV24.ShortcutGroup? = null + companion object { const val DIALOG_FRAGMENT_TAG = "dialog" @@ -620,3 +711,7 @@ fun Fragment.requireAnkiActivity(): AnkiActivity { return requireActivity() as? AnkiActivity? ?: throw java.lang.IllegalStateException("Fragment $this not attached to an AnkiActivity.") } + +interface AnkiActivityProvider { + val ankiActivity: AnkiActivity +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index 9d7715d938aa..bf91fa6f3ac3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt @@ -188,7 +188,7 @@ open class AnkiDroidApp : Application(), Configuration.Provider, ChangeManager.S LanguageUtil.setDefaultBackendLanguages() // Create the AnkiDroid directory if missing. Send exception report if inaccessible. - if (Permissions.hasStorageAccessPermission(this)) { + if (Permissions.hasLegacyStorageAccessPermission(this)) { try { val dir = CollectionHelper.getCurrentAnkiDroidDirectory(this) CollectionHelper.initializeAnkiDroidDirectory(dir) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiFragment.kt index 1c34f059cd9b..e92a52ca0762 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiFragment.kt @@ -29,6 +29,7 @@ import androidx.appcompat.widget.Toolbar import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import com.ichi2.async.CollectionLoader +import com.ichi2.compat.CompatV24 import com.ichi2.libanki.Collection import com.ichi2.utils.increaseHorizontalPaddingOfOverflowMenuIcons import com.ichi2.utils.tintOverflowMenuIcons @@ -47,12 +48,12 @@ import timber.log.Timber */ // TODO: Consider refactoring to create AnkiInterface to consolidate common implementations between AnkiFragment and AnkiActivity. // This could help reduce code repetition and improve maintainability. -open class AnkiFragment(@LayoutRes layout: Int) : Fragment(layout) { +open class AnkiFragment(@LayoutRes layout: Int) : Fragment(layout), AnkiActivityProvider { val getColUnsafe: Collection get() = CollectionManager.getColUnsafe() - val ankiActivity: AnkiActivity + override val ankiActivity: AnkiActivity get() = requireAnkiActivity() val mainToolbar: Toolbar @@ -218,4 +219,9 @@ open class AnkiFragment(@LayoutRes layout: Int) : Fragment(layout) { requireActivity().finish() return false } + + /** + * Lists of shortcuts for this fragment, and the IdRes of the name of this shortcut group. + */ + open val shortcuts: CompatV24.ShortcutGroup? = null } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt index 4e89b07fafe3..c4154b093914 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt @@ -18,11 +18,9 @@ package com.ichi2.anki -import android.content.BroadcastReceiver import android.content.Context import android.content.DialogInterface import android.content.Intent -import android.content.IntentFilter import android.content.res.Configuration import android.os.Bundle import android.os.SystemClock @@ -52,7 +50,6 @@ import androidx.annotation.MainThread import androidx.annotation.VisibleForTesting import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.ThemeUtils -import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import anki.collection.OpChanges @@ -99,9 +96,9 @@ import com.ichi2.anki.model.SortType import com.ichi2.anki.noteeditor.NoteEditorLauncher import com.ichi2.anki.preferences.sharedPrefs import com.ichi2.anki.previewer.PreviewerFragment -import com.ichi2.anki.receiver.SdCardReceiver import com.ichi2.anki.scheduling.ForgetCardsDialog import com.ichi2.anki.scheduling.SetDueDateDialog +import com.ichi2.anki.scheduling.registerOnForgetHandler import com.ichi2.anki.servicelayer.NoteService import com.ichi2.anki.servicelayer.NoteService.isMarked import com.ichi2.anki.servicelayer.avgIntervalOfNote @@ -116,7 +113,9 @@ import com.ichi2.anki.utils.roundedTimeSpanUnformatted import com.ichi2.anki.widgets.DeckDropDownAdapter.SubtitleListener import com.ichi2.annotations.NeedsTest import com.ichi2.async.renderBrowserQA -import com.ichi2.compat.CompatHelper.Companion.registerReceiverCompat +import com.ichi2.compat.CompatHelper +import com.ichi2.compat.CompatV24 +import com.ichi2.compat.shortcut import com.ichi2.libanki.Card import com.ichi2.libanki.CardId import com.ichi2.libanki.ChangeManager @@ -148,6 +147,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import net.ankiweb.rsdroid.RustCleanup +import net.ankiweb.rsdroid.Translations import timber.log.Timber import kotlin.math.abs import kotlin.math.ceil @@ -270,11 +270,6 @@ open class CardBrowser : private var shouldRestoreScroll = false private var postAutoScroll = false - /** - * Broadcast that informs us when the sd card is about to be unmounted - */ - private var unmountReceiver: BroadcastReceiver? = null - init { ChangeManager.subscribe(this) } @@ -429,6 +424,7 @@ open class CardBrowser : } setupFlows() + registerOnForgetHandler { viewModel.queryAllSelectedCardIds() } } @Suppress("UNUSED_PARAMETER") @@ -584,7 +580,7 @@ open class CardBrowser : override fun onCollectionLoaded(col: Collection) { super.onCollectionLoaded(col) Timber.d("onCollectionLoaded()") - registerExternalStorageListener() + registerReceiver() cards.reset() cardsListView.setOnItemClickListener { _: AdapterView<*>?, view: View?, position: Int, _: Long -> @@ -680,6 +676,10 @@ open class CardBrowser : Timber.i("Ctrl+K: Toggle Mark") toggleMark() return true + } else if (event.isAltPressed) { + Timber.i("Alt+K: Show keyboard shortcuts dialog") + CompatHelper.compat.showKeyboardShortcutsDialog(this) + return true } } KeyEvent.KEYCODE_R -> { @@ -888,9 +888,6 @@ open class CardBrowser : override fun onDestroy() { invalidate() super.onDestroy() - if (unmountReceiver != null) { - unregisterReceiver(unmountReceiver) - } } @Deprecated("Deprecated in Java") @@ -1085,17 +1082,10 @@ open class CardBrowser : } } actionBarMenu.findItem(R.id.action_delete_card).apply { - this.title = if (viewModel.cardsOrNotes == CARDS) { - resources.getQuantityString( - R.plurals.card_browser_delete_cards, - viewModel.selectedRowCount() - ) - } else { - resources.getQuantityString( - R.plurals.card_browser_delete_notes, - viewModel.selectedRowCount() - ) - } + this.title = resources.getQuantityString( + R.plurals.card_browser_delete_notes, + viewModel.selectedNoteCount() + ) } actionBarMenu.findItem(R.id.action_select_all).isVisible = !hasSelectedAllCards() // Note: Theoretically should not happen, as this should kick us back to the menu @@ -1401,11 +1391,7 @@ open class CardBrowser : private fun onResetProgress() { if (warnUserIfInNotesOnlyMode()) return - launchCatchingTask { - val cardIds = viewModel.queryAllSelectedCardIds() - val dialog = ForgetCardsDialog.newInstance(cardIds = cardIds) - showDialogFragment(dialog) - } + showDialogFragment(ForgetCardsDialog()) } @VisibleForTesting @@ -2323,24 +2309,6 @@ open class CardBrowser : } } - /** - * Show/dismiss dialog when sd card is ejected/remounted (collection is saved by SdCardReceiver) - */ - private fun registerExternalStorageListener() { - if (unmountReceiver == null) { - unmountReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == SdCardReceiver.MEDIA_EJECT) { - finish() - } - } - } - val iFilter = IntentFilter() - iFilter.addAction(SdCardReceiver.MEDIA_EJECT) - registerReceiverCompat(unmountReceiver, iFilter, ContextCompat.RECEIVER_EXPORTED) - } - } - /** * The views expand / contract when switching between multi-select mode so we manually * adjust so that the vertical position of the given view is maintained @@ -2403,6 +2371,42 @@ open class CardBrowser : } } + override val shortcuts + get() = CompatV24.ShortcutGroup( + listOf( + shortcut("Ctrl+Shift+A", R.string.edit_tags_dialog), + shortcut("Ctrl+A", R.string.card_browser_select_all), + shortcut("Ctrl+Shift+E", Translations::exportingExport), + shortcut("Ctrl+E", R.string.menu_add_note), + shortcut("E", R.string.cardeditor_title_edit_card), + shortcut("Ctrl+D", R.string.card_browser_change_deck), + shortcut("Ctrl+K", Translations::browsingToggleMark), + shortcut("Ctrl+Alt+R", Translations::browsingReschedule), + shortcut("DEL", R.string.delete_card_title), + shortcut("Ctrl+Alt+N", R.string.reset_card_dialog_title), + shortcut("Ctrl+Alt+T", R.string.toggle_cards_notes), + shortcut("Ctrl+T", R.string.card_browser_search_by_tag), + shortcut("Ctrl+Shift+S", Translations::actionsReposition), + shortcut("Ctrl+Alt+S", R.string.card_browser_list_my_searches), + shortcut("Ctrl+S", R.string.card_browser_list_my_searches_save), + shortcut("Alt+S", R.string.card_browser_show_suspended), + shortcut("Ctrl+Shift+J", Translations::browsingToggleBury), + shortcut("Ctrl+J", Translations::browsingToggleSuspend), + shortcut("Ctrl+Shift+I", Translations::actionsCardInfo), + shortcut("Ctrl+O", R.string.show_order_dialog), + shortcut("Ctrl+M", R.string.card_browser_show_marked), + shortcut("Esc", R.string.card_browser_select_none), + shortcut("Ctrl+1", R.string.gesture_flag_red), + shortcut("Ctrl+2", R.string.gesture_flag_orange), + shortcut("Ctrl+3", R.string.gesture_flag_green), + shortcut("Ctrl+4", R.string.gesture_flag_blue), + shortcut("Ctrl+5", R.string.gesture_flag_pink), + shortcut("Ctrl+6", R.string.gesture_flag_turquoise), + shortcut("Ctrl+7", R.string.gesture_flag_purple) + ), + R.string.card_browser_context_menu + ) + companion object { /** * Argument key to add on change deck dialog, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index c43bdf9a8038..ef3f9cb031b8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt @@ -73,6 +73,8 @@ import com.ichi2.anki.utils.ext.isImageOcclusion import com.ichi2.anki.utils.postDelayed import com.ichi2.annotations.NeedsTest import com.ichi2.compat.CompatHelper.Companion.getSerializableCompat +import com.ichi2.compat.CompatV24 +import com.ichi2.compat.shortcut import com.ichi2.libanki.Collection import com.ichi2.libanki.Note import com.ichi2.libanki.NoteId @@ -86,6 +88,7 @@ import com.ichi2.ui.FixedTextView import com.ichi2.utils.KotlinCleanup import com.ichi2.utils.copyToClipboard import com.ichi2.utils.jsonObjectIterable +import net.ankiweb.rsdroid.Translations import org.json.JSONArray import org.json.JSONException import org.json.JSONObject @@ -203,12 +206,11 @@ open class CardTemplateEditor : AnkiActivity(), DeckSelectionListener { tags = note.tags, fillEmpty = true ) - val fragment = TemplatePreviewerFragment.newInstance(args) + val backgroundColor = ThemeUtils.getThemeAttrColor(this@CardTemplateEditor, R.attr.alternativeBackgroundColor) + val fragment = TemplatePreviewerFragment.newInstance(args, backgroundColor) supportFragmentManager.commitNow { replace(R.id.fragment_container, fragment) } - val backgroundColor = ThemeUtils.getThemeAttrColor(this@CardTemplateEditor, R.attr.alternativeBackgroundColor) - fragment.requireView().setBackgroundColor(backgroundColor) } } @@ -249,6 +251,9 @@ open class CardTemplateEditor : AnkiActivity(), DeckSelectionListener { */ override fun onCollectionLoaded(col: Collection) { super.onCollectionLoaded(col) + // without this call the editor doesn't see the latest changes to notetypes, see #16630 + @NeedsTest("Add test to check that renaming notetypes in ManageNotetypes is seen in CardTemplateEditor(#16630)") + col.notetypes.clearCache() // The first time the activity loads it has a model id but no edits yet, so no edited model // take the passed model id load it up for editing if (tempModel == null) { @@ -323,61 +328,62 @@ open class CardTemplateEditor : AnkiActivity(), DeckSelectionListener { override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { val currentFragment = currentFragment ?: return super.onKeyUp(keyCode, event) - if (event.isCtrlPressed) { - when (keyCode) { - KeyEvent.KEYCODE_P -> { - Timber.i("Ctrl+P: Perform preview from keypress") - currentFragment.performPreview() - } - KeyEvent.KEYCODE_1 -> { - Timber.i("Ctrl+1: Edit front template from keypress") - currentFragment.bottomNavigation.selectedItemId = R.id.front_edit - } - KeyEvent.KEYCODE_2 -> { - Timber.i("Ctrl+2: Edit back template from keypress") - currentFragment.bottomNavigation.selectedItemId = R.id.back_edit - } - KeyEvent.KEYCODE_3 -> { - Timber.i("Ctrl+3: Edit styling from keypress") - currentFragment.bottomNavigation.selectedItemId = R.id.styling_edit - } - KeyEvent.KEYCODE_S -> { - Timber.i("Ctrl+S: Save note from keypress") - currentFragment.saveNoteType() - } - KeyEvent.KEYCODE_I -> { - Timber.i("Ctrl+I: Insert field from keypress") - currentFragment.showInsertFieldDialog() - } - KeyEvent.KEYCODE_A -> { - Timber.i("Ctrl+A: Add card template from keypress") - currentFragment.addCardTemplate() - } - KeyEvent.KEYCODE_R -> { - Timber.i("Ctrl+R: Rename card from keypress") - currentFragment.showRenameDialog() - } - KeyEvent.KEYCODE_B -> { - Timber.i("Ctrl+B: Open browser appearance from keypress") - currentFragment.openBrowserAppearance() - } - KeyEvent.KEYCODE_D -> { - Timber.i("Ctrl+D: Delete card from keypress") - currentFragment.deleteCardTemplate() - } - KeyEvent.KEYCODE_O -> { - Timber.i("Ctrl+O: Display deck override dialog from keypress") - currentFragment.displayDeckOverrideDialog(currentFragment.tempModel) - } - KeyEvent.KEYCODE_M -> { - Timber.i("Ctrl+M: Copy markdown from keypress") - currentFragment.copyMarkdownTemplateToClipboard() - } - else -> return super.onKeyUp(keyCode, event) + if (!event.isCtrlPressed) { return super.onKeyUp(keyCode, event) } + when (keyCode) { + KeyEvent.KEYCODE_P -> { + Timber.i("Ctrl+P: Perform preview from keypress") + currentFragment.performPreview() + } + KeyEvent.KEYCODE_1 -> { + Timber.i("Ctrl+1: Edit front template from keypress") + currentFragment.bottomNavigation.selectedItemId = R.id.front_edit + } + KeyEvent.KEYCODE_2 -> { + Timber.i("Ctrl+2: Edit back template from keypress") + currentFragment.bottomNavigation.selectedItemId = R.id.back_edit + } + KeyEvent.KEYCODE_3 -> { + Timber.i("Ctrl+3: Edit styling from keypress") + currentFragment.bottomNavigation.selectedItemId = R.id.styling_edit + } + KeyEvent.KEYCODE_S -> { + Timber.i("Ctrl+S: Save note from keypress") + currentFragment.saveNoteType() + } + KeyEvent.KEYCODE_I -> { + Timber.i("Ctrl+I: Insert field from keypress") + currentFragment.showInsertFieldDialog() + } + KeyEvent.KEYCODE_A -> { + Timber.i("Ctrl+A: Add card template from keypress") + currentFragment.addCardTemplate() + } + KeyEvent.KEYCODE_R -> { + Timber.i("Ctrl+R: Rename card from keypress") + currentFragment.showRenameDialog() + } + KeyEvent.KEYCODE_B -> { + Timber.i("Ctrl+B: Open browser appearance from keypress") + currentFragment.openBrowserAppearance() + } + KeyEvent.KEYCODE_D -> { + Timber.i("Ctrl+D: Delete card from keypress") + currentFragment.deleteCardTemplate() + } + KeyEvent.KEYCODE_O -> { + Timber.i("Ctrl+O: Display deck override dialog from keypress") + currentFragment.displayDeckOverrideDialog(currentFragment.tempModel) + } + KeyEvent.KEYCODE_M -> { + Timber.i("Ctrl+M: Copy markdown from keypress") + currentFragment.copyMarkdownTemplateToClipboard() + } + else -> { + return super.onKeyUp(keyCode, event) } - return true } - return super.onKeyUp(keyCode, event) + // We reach this only if we didn't reach the `else` case. + return true } @get:VisibleForTesting @@ -422,6 +428,25 @@ open class CardTemplateEditor : AnkiActivity(), DeckSelectionListener { } } + override val shortcuts + get() = CompatV24.ShortcutGroup( + listOf( + shortcut("Ctrl+P", R.string.card_editor_preview_card), + shortcut("Ctrl+1", R.string.edit_front_template), + shortcut("Ctrl+2", R.string.edit_back_template), + shortcut("Ctrl+3", R.string.edit_styling), + shortcut("Ctrl+S", R.string.save), + shortcut("Ctrl+I", R.string.card_template_editor_insert_field), + shortcut("Ctrl+A", Translations::cardTemplatesAddCardType), + shortcut("Ctrl+R", Translations::cardTemplatesRenameCardType), + shortcut("Ctrl+B", R.string.edit_browser_appearance), + shortcut("Ctrl+D", Translations::cardTemplatesRemoveCardType), + shortcut("Ctrl+O", Translations::cardTemplatesDeckOverride), + shortcut("Ctrl+M", R.string.copy_the_template) + ), + R.string.card_template_editor_group + ) + class CardTemplateFragment : Fragment() { private val refreshFragmentHandler = Handler(Looper.getMainLooper()) private var currentEditorTitle: FixedTextView? = null diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index 2f94781f045c..ecc08d6c3baf 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -26,10 +26,8 @@ package com.ichi2.anki import android.app.Activity -import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.content.SharedPreferences import android.database.SQLException import android.graphics.PixelFormat @@ -59,7 +57,6 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat.OnRequestPermissionsResultCallback -import androidx.core.content.ContextCompat import androidx.core.content.edit import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat @@ -154,8 +151,9 @@ import com.ichi2.annotations.NeedsTest import com.ichi2.async.deleteMedia import com.ichi2.compat.CompatHelper import com.ichi2.compat.CompatHelper.Companion.getSerializableCompat -import com.ichi2.compat.CompatHelper.Companion.registerReceiverCompat import com.ichi2.compat.CompatHelper.Companion.sdkVersion +import com.ichi2.compat.CompatV24 +import com.ichi2.compat.shortcut import com.ichi2.libanki.ChangeManager import com.ichi2.libanki.Consts import com.ichi2.libanki.DeckId @@ -193,6 +191,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import net.ankiweb.rsdroid.RustCleanup +import net.ankiweb.rsdroid.Translations import org.json.JSONException import timber.log.Timber import java.io.File @@ -264,8 +263,6 @@ open class DeckPicker : private lateinit var reviewSummaryTextView: TextView - @KotlinCleanup("make lateinit, but needs more changes") - private var unmountReceiver: BroadcastReceiver? = null private lateinit var floatingActionMenu: DeckPickerFloatingActionMenu // flag asking user to do a full sync which is used in upgrade path @@ -503,7 +500,7 @@ open class DeckPicker : if (fragmented && !startupError) { loadStudyOptionsFragment(false) } - registerExternalStorageListener() + registerReceiver() // create inherited navigation drawer layout here so that it can be used by parent class initNavigationDrawer(mainView) @@ -1167,9 +1164,6 @@ open class DeckPicker : override fun onDestroy() { super.onDestroy() - if (unmountReceiver != null) { - unregisterReceiver(unmountReceiver) - } if (progressDialog != null && progressDialog!!.isShowing) { progressDialog!!.dismiss() } @@ -1702,11 +1696,6 @@ open class DeckPicker : showAsyncDialogFragment(newFragment, Channel.SYNC) } - fun onSdCardNotMounted() { - showThemedToast(this, resources.getString(R.string.sd_card_not_mounted), false) - finish() - } - // Callback method to submit error report fun sendErrorReport() { CrashReportService.sendExceptionReport(RuntimeException(), "DeckPicker.sendErrorReport") @@ -1898,25 +1887,12 @@ open class DeckPicker : } /** - * Show a message when the SD card is ejected + * Refresh the deck picker when the SD card is inserted. */ - private fun registerExternalStorageListener() { - if (unmountReceiver == null) { - unmountReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == SdCardReceiver.MEDIA_EJECT) { - onSdCardNotMounted() - } else if (intent.action == SdCardReceiver.MEDIA_MOUNT) { - ActivityCompat.recreate(this@DeckPicker) - } - } - } - val iFilter = IntentFilter() - iFilter.addAction(SdCardReceiver.MEDIA_EJECT) - iFilter.addAction(SdCardReceiver.MEDIA_MOUNT) - registerReceiverCompat(unmountReceiver, iFilter, ContextCompat.RECEIVER_EXPORTED) - } - } + override val broadcastsActions = super.broadcastsActions + mapOf( + SdCardReceiver.MEDIA_MOUNT + to { ActivityCompat.recreate(this) } + ) fun openAnkiWebSharedDecks() { val intent = Intent(this, SharedDecksActivity::class.java) @@ -2416,6 +2392,30 @@ open class DeckPicker : SKIP_STUDY_OPTIONS } + override val shortcuts + get() = CompatV24.ShortcutGroup( + listOf( + shortcut("A", R.string.menu_add_note), + shortcut("B", R.string.card_browser_context_menu), + shortcut("Y", R.string.pref_cat_sync), + shortcut("/", R.string.deck_conf_cram_search), + shortcut("S", Translations::decksStudyDeck), + shortcut("T", R.string.open_statistics), + shortcut("C", R.string.check_db), + shortcut("D", R.string.new_deck), + shortcut("F", R.string.new_dynamic_deck), + shortcut("DEL", R.string.delete_deck_title), + shortcut("Shift+DEL", R.string.delete_deck_without_confirmation), + shortcut("R", R.string.rename_deck), + shortcut("P", R.string.open_settings), + shortcut("M", R.string.check_media), + shortcut("Ctrl+E", R.string.export_collection), + shortcut("Ctrl+Shift+I", R.string.menu_import), + shortcut("Ctrl+Shift+N", R.string.model_browser_label) + ), + R.string.deck_picker_group + ) + companion object { /** * Result codes from other activities diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Ease.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Ease.kt index 9266c2c3fb05..632fd2ec7b45 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/Ease.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/Ease.kt @@ -15,6 +15,9 @@ */ package com.ichi2.anki +/** + * [value] should be kept in sync with the [com.ichi2.anki.api.Ease] enum. + */ enum class Ease(val value: Int) { AGAIN(1), HARD(2), diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt b/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt index 89307dd06981..5e4c496e6136 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt @@ -40,7 +40,7 @@ import com.ichi2.utils.ImportUtils.showImportUnsuccessfulDialog import com.ichi2.utils.IntentUtil.resolveMimeType import com.ichi2.utils.NetworkUtils import com.ichi2.utils.Permissions -import com.ichi2.utils.Permissions.hasStorageAccessPermission +import com.ichi2.utils.Permissions.hasLegacyStorageAccessPermission import com.ichi2.utils.copyToClipboard import com.ichi2.utils.trimToLength import timber.log.Timber @@ -272,7 +272,7 @@ class IntentHandler : AbstractIntentHandler() { * @return `true`: if granted, otherwise `false` and shows a missing permission toast */ fun grantedStoragePermissions(context: Context, showToast: Boolean): Boolean { - val granted = !ScopedStorageService.isLegacyStorage(context) || hasStorageAccessPermission(context) || Permissions.isExternalStorageManagerCompat() + val granted = !ScopedStorageService.isLegacyStorage(context) || hasLegacyStorageAccessPermission(context) || Permissions.isExternalStorageManagerCompat() if (!granted && showToast) { showThemedToast(context, context.getString(R.string.intent_handler_failed_no_storage_permission), false) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ModelFieldEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ModelFieldEditor.kt index 8aa551e958d6..6c0dfe4a4152 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ModelFieldEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ModelFieldEditor.kt @@ -434,6 +434,7 @@ class ModelFieldEditor : AnkiActivity(), LocaleSelectionDialogHandler { withCol { Timber.d("doInBackgroundChangeSortField") notetypes.setSortIndex(notetype, idx) + notetypes.save(notetype) } } initialize() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/MyAccount.kt b/AnkiDroid/src/main/java/com/ichi2/anki/MyAccount.kt index 51a14d98d493..f38fc87f371b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/MyAccount.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/MyAccount.kt @@ -58,7 +58,8 @@ open class MyAccount : AnkiActivity() { var toolbar: Toolbar? = null private lateinit var passwordLayout: TextInputLayout - private lateinit var ankidroidLogo: ImageView + private lateinit var loginLogo: ImageView + private lateinit var loggedInLogo: ImageView // if the 'remove account' fragment is open, close it first private val onRemoveAccountBackCallback = object : OnBackPressedCallback(false) { @@ -110,9 +111,11 @@ open class MyAccount : AnkiActivity() { switchToState(STATE_LOG_IN) } if (isScreenSmall && this.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { - ankidroidLogo.visibility = View.GONE + loginLogo.visibility = View.GONE + loggedInLogo.visibility = View.GONE } else { - ankidroidLogo.visibility = View.VISIBLE + loginLogo.visibility = View.VISIBLE + loggedInLogo.visibility = View.VISIBLE } onBackPressedDispatcher.addCallback(this, onRemoveAccountBackCallback) } @@ -181,7 +184,7 @@ open class MyAccount : AnkiActivity() { userNameLayout = it.findViewById(R.id.username_layout) password = it.findViewById(R.id.password) passwordLayout = it.findViewById(R.id.password_layout) - ankidroidLogo = it.findViewById(R.id.ankidroid_logo) + loginLogo = it.findViewById(R.id.login_logo) } val loginButton = loginToMyAccountView.findViewById