diff --git a/.github/ISSUE_TEMPLATE/bug_report_form.yml b/.github/ISSUE_TEMPLATE/bug_report_form.yml index 43a57160cf84..c83a0326bcdf 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_form.yml +++ b/.github/ISSUE_TEMPLATE/bug_report_form.yml @@ -9,7 +9,7 @@ body: id: contact attributes: label: Checked for duplicates? - description: Quickly search our [open issues](https://github.com/ankidroid/Anki-Android/issues) to see if a similar issue exists + description: Quickly search our [issues](https://github.com/ankidroid/Anki-Android/issues?q=is%3Aissue) to see if a similar issue exists options: - label: This issue is not a duplicate required: true diff --git a/.github/workflows/compare_apk_size.yml b/.github/workflows/compare_apk_size.yml index de3ba0161c3c..e50717b487f0 100644 --- a/.github/workflows/compare_apk_size.yml +++ b/.github/workflows/compare_apk_size.yml @@ -47,7 +47,6 @@ jobs: timeout-minutes: 5 with: cache-read-only: true - gradle-home-cache-cleanup: true - name: Checkout PR uses: actions/checkout@v4 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1d926400873e..99888b54a94c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -37,7 +37,6 @@ jobs: # Builds on other branches will only read from main branch cache writes # Comment this and the with: above out for performance testing on a branch cache-read-only: ${{ github.ref != 'refs/heads/main' }} - gradle-home-cache-cleanup: true - name: Warm Gradle Cache # This makes sure we fetch gradle network resources with a retry diff --git a/.github/workflows/opencollective_notices.yml b/.github/workflows/opencollective_notices.yml index dd78b6eeb26a..81ab184c3eaf 100644 --- a/.github/workflows/opencollective_notices.yml +++ b/.github/workflows/opencollective_notices.yml @@ -50,6 +50,9 @@ jobs: If you are interested in compensation for this work, the process with details is here: https://github.com/ankidroid/Anki-Android/wiki/OpenCollective-Payment-Process#how-to-get-paid + + > [!IMPORTANT] + > PLEASE NOTE: The process was updated in August 2024. Re-read the Payment Process page if you have not already. We only post one comment per person per month to avoid spamming you, regardless of the number of PRs merged, but this note applies to all PRs merged for this month diff --git a/.github/workflows/tests_emulator.yml b/.github/workflows/tests_emulator.yml index ae00ab29ffd5..3975611f50c4 100644 --- a/.github/workflows/tests_emulator.yml +++ b/.github/workflows/tests_emulator.yml @@ -84,7 +84,6 @@ jobs: # Builds on other branches will only read from main branch cache writes # Comment this and the with: above out for performance testing on a branch cache-read-only: ${{ github.ref != 'refs/heads/main' }} - gradle-home-cache-cleanup: true # This appears to be 'Cache Size: ~1230 MB (1290026823 B)' based on watching action logs # Repo limit is 10GB; branch caches are independent; branches may read default branch cache. diff --git a/.github/workflows/tests_unit.yml b/.github/workflows/tests_unit.yml index ad4bd021bc90..7a041bfbee1f 100644 --- a/.github/workflows/tests_unit.yml +++ b/.github/workflows/tests_unit.yml @@ -143,9 +143,6 @@ jobs: # Builds on other branches will only read from main branch cache writes # Comment this and the with: above out for performance testing on a branch cache-read-only: ${{ github.ref != 'refs/heads/main' }} - # gradle-home-cache-cleanup is temporarily disabled to investigate cache pollution issues - # Requested in: https://github.com/gradle/actions/issues/167#issuecomment-2052352341 - # gradle-home-cache-cleanup: true - name: Gradle Dependency Download uses: nick-invision/retry@v3 diff --git a/AnkiDroid/src/main/AndroidManifest.xml b/AnkiDroid/src/main/AndroidManifest.xml index 2cb577be3db0..c051cf5a9d90 100644 --- a/AnkiDroid/src/main/AndroidManifest.xml +++ b/AnkiDroid/src/main/AndroidManifest.xml @@ -538,6 +538,33 @@ + + + + + + + + + + + + + + + + diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt index 8aec5d8d1c5a..fc7b53fd15e1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt @@ -21,12 +21,12 @@ import android.os.Bundle import android.speech.tts.TextToSpeech import android.speech.tts.TextToSpeech.ERROR import androidx.annotation.CheckResult -import com.ichi2.anki.AndroidTtsError.TtsErrorCode import com.ichi2.compat.UtteranceProgressListenerCompat import com.ichi2.libanki.TTSTag import com.ichi2.libanki.TtsPlayer import com.ichi2.libanki.TtsPlayer.TtsCompletionStatus import com.ichi2.libanki.TtsVoice +import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel @@ -72,7 +72,8 @@ class AndroidTtsPlayer(private val context: Context, private val voices: List @@ -106,12 +107,12 @@ class AndroidTtsPlayer(private val context: Context, private val voices: List if (it.setSpeechRate(speed) == ERROR) { - return@suspendCancellableCoroutine continuation.resume(AndroidTtsError.failure(TtsErrorCode.APP_SPEECH_RATE_FAILED)) + return@suspendCancellableCoroutine continuation.resume(AndroidTtsError.SpeechRateFailed) } } // if it's already playing: stop it it.stopPlaying() - } ?: return@suspendCancellableCoroutine continuation.resume(AndroidTtsError.failure(TtsErrorCode.APP_TTS_INIT_FAILED)) + } ?: return@suspendCancellableCoroutine continuation.resume(AndroidTtsError.InitFailed) Timber.d("tts text '%s' to be played for locale (%s)", tag.fieldText, tag.lang) continuation.ensureActive() @@ -163,56 +164,72 @@ class AndroidTtsPlayer(private val context: Context, private val voices: List "ERROR" - ERROR_SYNTHESIS -> "ERROR_SYNTHESIS" - ERROR_INVALID_REQUEST -> "ERROR_INVALID_REQUEST" - ERROR_NETWORK -> "ERROR_NETWORK" - ERROR_NETWORK_TIMEOUT -> "ERROR_NETWORK_TIMEOUT" - ERROR_NOT_INSTALLED_YET -> "ERROR_NOT_INSTALLED_YET" - ERROR_OUTPUT -> "ERROR_OUTPUT" - ERROR_SERVICE -> "ERROR_SERVICE" - APP_MISSING_VOICE -> "APP_MISSING_VOICE" - APP_INVALID_VOICE -> "APP_INVALID_VOICE" - APP_SPEECH_RATE_FAILED -> "APP_SPEECH_RATE_FAILED" - APP_TTS_INIT_FAILED -> "APP_TTS_INIT_FAILED" - APP_TTS_INIT_TIMEOUT -> "APP_TTS_INIT_TIMEOUT" - APP_UNKNOWN -> "APP_UNKNOWN" - } - - companion object { - fun fromErrorCode(errorCode: Int): TtsErrorCode = - entries.firstOrNull { it.code == errorCode } ?: APP_UNKNOWN - } - } +sealed class AndroidTtsError : TtsPlayer.TtsError() { + // Ankidroid specific errors + data object UnknownError : AndroidTtsError() + data class MissingVoiceError(val tag: TTSTag) : AndroidTtsError() + data object InvalidVoiceError : AndroidTtsError() + data object SpeechRateFailed : AndroidTtsError() + data object InitFailed : AndroidTtsError() + data object InitTimeout : AndroidTtsError() + + // Android Errors + /** @see TextToSpeech.ERROR */ + private data object AndroidGenericError : AndroidTtsError() + + /** @see TextToSpeech.ERROR_SYNTHESIS */ + private data object AndroidSynthesisError : AndroidTtsError() + + /** @see TextToSpeech.ERROR_INVALID_REQUEST */ + private data object AndroidInvalidRequest : AndroidTtsError() + + /** @see TextToSpeech.ERROR_NETWORK */ + private data object AndroidNetworkError : AndroidTtsError() + + /** @see TextToSpeech.ERROR_NETWORK_TIMEOUT */ + private data object AndroidNetworkTimeoutError : AndroidTtsError() + + /** @see TextToSpeech.ERROR_NOT_INSTALLED_YET */ + private data object AndroidNotInstalledYet : AndroidTtsError() + + /** @see TextToSpeech.ERROR_OUTPUT */ + private data object AndroidOutputError : AndroidTtsError() + + /** @see TextToSpeech.ERROR_SERVICE */ + private data object AndroidServiceError : AndroidTtsError() + + /** A string which google will relate to the TTS Engine in most cases */ + val developerString: String + get() = + when (this) { + is AndroidGenericError -> "ERROR" + is AndroidSynthesisError -> "ERROR_SYNTHESIS" + is AndroidInvalidRequest -> "ERROR_INVALID_REQUEST" + is AndroidNetworkError -> "ERROR_NETWORK_ERROR" + is AndroidNetworkTimeoutError -> "ERROR_NETWORK_TIMEOUT" + is AndroidNotInstalledYet -> "ERROR_NOT_INSTALLED_YET" + is AndroidOutputError -> "ERROR_OUTPUT" + is AndroidServiceError -> "ERROR_SERVICE" + is MissingVoiceError -> "APP_MISSING_VOICE" + is InvalidVoiceError -> "APP_INVALID_VOICE" + is SpeechRateFailed -> "APP_SPEECH_RATE_FAILED" + is InitFailed -> "APP_TTS_INIT_FAILED" + is InitTimeout -> "APP_TTS_INIT_TIMEOUT" + is UnknownError -> "APP_UNKNOWN" + } companion object { - fun failure(errorCode: TtsErrorCode): TtsCompletionStatus = - TtsCompletionStatus.failure(AndroidTtsError(errorCode)) - - fun failure(errorCode: Int): TtsCompletionStatus = - failure(TtsErrorCode.fromErrorCode(errorCode)) + fun fromErrorCode(errorCode: Int): AndroidTtsError = + when (errorCode) { + ERROR -> AndroidGenericError + TextToSpeech.ERROR_SYNTHESIS -> AndroidSynthesisError + TextToSpeech.ERROR_INVALID_REQUEST -> AndroidInvalidRequest + TextToSpeech.ERROR_NETWORK -> AndroidNetworkError + TextToSpeech.ERROR_OUTPUT -> AndroidOutputError + TextToSpeech.ERROR_NOT_INSTALLED_YET -> AndroidNotInstalledYet + TextToSpeech.ERROR_SERVICE -> AndroidServiceError + else -> UnknownError + } } } @@ -221,7 +238,11 @@ fun TtsPlayer.TtsError.localizedErrorMessage(context: Context): String = // TODO: Do we want a human readable string here as well - snackbar has limited room // but developerString is currently not translated as it returns // developerString: ERROR_NETWORK_TIMEOUT, so "Audio error (ERROR_NETWORK_TIMEOUT)" - context.getString(R.string.tts_voices_playback_error_new, this.errorCode.developerString) + context.getString(R.string.tts_voices_playback_error_new, this.developerString) } else { this.toString() } + +private fun CancellableContinuation.resume(error: AndroidTtsError) { + resume(TtsCompletionStatus.failure(error)) +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index 88ba05778cd5..9d7715d938aa 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt @@ -60,6 +60,7 @@ import com.ichi2.utils.ExceptionUtil import com.ichi2.utils.KotlinCleanup import com.ichi2.utils.LanguageUtil import com.ichi2.utils.Permissions +import com.ichi2.widget.cardanalysis.CardAnalysisWidget import com.ichi2.widget.deckpicker.DeckPickerWidget import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -298,6 +299,7 @@ open class AnkiDroidApp : Application(), Configuration.Provider, ChangeManager.S Timber.d("ChangeSubscriber - opExecuted called with changes: $changes") if (changes.studyQueues) { DeckPickerWidget.updateDeckPickerWidgets(this) + CardAnalysisWidget.updateCardAnalysisWidgets(this) } else { Timber.d("No relevant changes to update the widget") } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index bf0daacf9c71..a730254bd346 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -1057,6 +1057,11 @@ open class DeckPicker : showDatabaseErrorDialog(DatabaseErrorDialogType.DIALOG_CONFIRM_RESTORE_BACKUP) return true } + R.id.action_export_collection -> { + Timber.i("DeckPicker:: Export menu item selected") + ExportDialogFragment.newInstance().show(supportFragmentManager, "exportDialog") + return true + } else -> return super.onOptionsItemSelected(item) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckSpinnerSelection.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckSpinnerSelection.kt index aab7566afda8..07fadb2c1df1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckSpinnerSelection.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckSpinnerSelection.kt @@ -202,7 +202,7 @@ class DeckSpinnerSelection( * Displays a [DeckSelectionDialog] */ suspend fun displayDeckSelectionDialog() { - val decks = fromCollection(includeFiltered = false).toMutableList() + val decks = fromCollection(includeFiltered = showFilteredDecks).toMutableList() if (showAllDecks) { decks.add(SelectableDeck(ALL_DECKS_ID, context.resources.getString(R.string.card_browser_all_decks))) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckUtils.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckUtils.kt new file mode 100644 index 000000000000..87d599c30a0e --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckUtils.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2024 Anoop + * + * 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 . + */ + +package com.ichi2.anki + +import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.libanki.Collection +import com.ichi2.libanki.Consts + +/** + * Checks if a given deck, including its subdecks if specified, is empty. + * + * @param deckId The ID of the deck to check. + * @param includeSubdecks If true, includes subdecks in the check. Default is true. + * @return `true` if the deck (and subdecks if specified) is empty, otherwise `false`. + */ +private fun Collection.isDeckEmpty(deckId: Long, includeSubdecks: Boolean = true): Boolean { + val deckIds = decks.deckAndChildIds(deckId) + val totalCardCount = decks.cardCount(*deckIds.toLongArray(), includeSubdecks = includeSubdecks) + return totalCardCount == 0 +} + +/** + * Checks if the default deck is empty. + * + * This method runs on an IO thread and accesses the collection to determine if the default deck (with ID 1) is empty. + * + * @return `true` if the default deck is empty, otherwise `false`. + */ +suspend fun isDefaultDeckEmpty(): Boolean = withCol { isDeckEmpty(Consts.DEFAULT_DECK_ID) } + +/** + * Returns whether the deck picker displays any deck. + * Technically, it means that there is a non-default deck, or that the default deck is non-empty. + * + * This function is specifically implemented to address an issue where the default deck + * isn't handled correctly when a second deck is added to the + * collection. In this case, the deck tree may incorrectly appear as non-empty when it contains + * only the default deck and no other cards. + * + */ +suspend fun isCollectionEmpty(): Boolean { + val tree = withCol { sched.deckDueTree() } + val onlyDefaultDeckAvailable = tree.children.singleOrNull()?.did == Consts.DEFAULT_DECK_ID + return onlyDefaultDeckAvailable && isDefaultDeckEmpty() +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt b/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt index 6ff6d87001e2..f57200fb3a32 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt @@ -58,6 +58,17 @@ object TtsVoices { /** A job which populates [availableLocaleData] */ private var buildLocalesJob: Job? = null + /** + * The package name of the default speech synthesis engine. + * + * @return Package name of the Tts engine that the user has chosen as their default. + * 'null' if the system has no engine or if an error occurs + * + * @see TextToSpeech.getDefaultEngine + */ + var ttsEngine: String? = null + private set + /** * Returns the list of available locales for use in TTS * @@ -188,6 +199,7 @@ object TtsVoices { textToSpeech = TextToSpeech(context) { status -> if (status == TextToSpeech.SUCCESS) { Timber.v("TTS creation success") + ttsEngine = textToSpeech?.defaultEngine continuation.resume(textToSpeech) } else { Timber.e("TTS creation failed. status: %d", status) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardMediaPlayer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardMediaPlayer.kt index 3fedf3ee2352..49d024219594 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardMediaPlayer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardMediaPlayer.kt @@ -24,15 +24,16 @@ import androidx.lifecycle.lifecycleScope import com.ichi2.anki.AbstractFlashcardViewer import com.ichi2.anki.AbstractFlashcardViewer.Companion.getMediaBaseUrl import com.ichi2.anki.AndroidTtsError -import com.ichi2.anki.AndroidTtsError.TtsErrorCode import com.ichi2.anki.AndroidTtsPlayer import com.ichi2.anki.AnkiDroidApp import com.ichi2.anki.CollectionHelper.getMediaDirectory import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.R import com.ichi2.anki.ReadText import com.ichi2.anki.cardviewer.SoundErrorBehavior.CONTINUE_AUDIO import com.ichi2.anki.cardviewer.SoundErrorBehavior.RETRY_AUDIO import com.ichi2.anki.cardviewer.SoundErrorBehavior.STOP_AUDIO +import com.ichi2.anki.dialogs.TtsPlaybackErrorDialog import com.ichi2.anki.localizedErrorMessage import com.ichi2.anki.reviewer.CardSide import com.ichi2.anki.snackbar.showSnackbar @@ -340,7 +341,7 @@ class CardMediaPlayer : Closeable { } if (player == null) { Timber.v("timeout waiting for TTS Player") - val error = AndroidTtsError(TtsErrorCode.APP_TTS_INIT_TIMEOUT) + val error = AndroidTtsError.InitTimeout soundErrorListener?.onTtsError(error, isAutomaticPlayback) } return player @@ -430,7 +431,13 @@ fun AbstractFlashcardViewer.createSoundErrorListener(): SoundErrorListener { override fun onTtsError(error: TtsPlayer.TtsError, isAutomaticPlayback: Boolean) { AbstractFlashcardViewer.mediaErrorHandler.processTtsFailure(error, isAutomaticPlayback) { - activity.showSnackbar(error.localizedErrorMessage(activity)) + when (error) { + is AndroidTtsError.MissingVoiceError -> + TtsPlaybackErrorDialog.ttsPlaybackErrorDialog(activity, supportFragmentManager, error.tag) + is AndroidTtsError.InvalidVoiceError -> + activity.showSnackbar(getString(R.string.voice_not_supported)) + else -> activity.showSnackbar(error.localizedErrorMessage(activity)) + } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/TtsPlaybackErrorDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/TtsPlaybackErrorDialog.kt new file mode 100644 index 000000000000..ac12846913e4 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/TtsPlaybackErrorDialog.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2024 RohanRaj123 + * + * 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 . + */ + +package com.ichi2.anki.dialogs + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.FragmentManager +import com.ichi2.anki.CrashReportService +import com.ichi2.anki.R +import com.ichi2.anki.TtsVoices +import com.ichi2.anki.utils.openUrl +import com.ichi2.libanki.TTSTag +import com.ichi2.utils.show +import timber.log.Timber + +object TtsPlaybackErrorDialog { + + fun ttsPlaybackErrorDialog(activity: Activity, fragmentManager: FragmentManager, ttsTag: TTSTag?) { + Timber.i("Dialog is shown to guide users correctly to troubleshoot the Tts error: Missing voice error") + activity.runOnUiThread { + AlertDialog.Builder(activity).show { + setTitle(activity.getString(R.string.tts_error_dialog_title)) + setMessage(activity.getString(R.string.tts_error_dialog_reason_text, TtsVoices.ttsEngine, ttsTag?.lang)) + setNegativeButton(context.getString(R.string.tts_error_dialog_change_button_text)) { _, _ -> openSettings(activity) } + setPositiveButton(activity.getString(R.string.tts_error_dialog_supported_voices_button_text)) { _, _ -> showVoicesDialog(fragmentManager) } + setNeutralButton(context.getString(R.string.help)) { _, _ -> + activity.openUrl(Uri.parse(context.getString(R.string.link_faq_tts))) + } + } + } + } + + private fun openSettings(activity: Activity) { + try { + Timber.i("Opening TextToSpeech engine settings to change the engine") + activity.startActivity( + Intent("com.android.settings.TTS_SETTINGS").apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK } + ) + } catch (e: Exception) { + CrashReportService.sendExceptionReport(e, e.localizedMessage) + } + } + + private fun showVoicesDialog(fragmentManager: FragmentManager) { + TtsVoicesDialogFragment().show(fragmentManager, "TTS_VOICES_DIALOG_FRAGMENT") + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/web/HttpFetcher.kt b/AnkiDroid/src/main/java/com/ichi2/anki/web/HttpFetcher.kt index 21fac2f2596f..405919a4df50 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/web/HttpFetcher.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/web/HttpFetcher.kt @@ -19,29 +19,23 @@ ****************************************************************************************/ package com.ichi2.anki.web -import android.content.Context -import com.ichi2.compat.CompatHelper import com.ichi2.utils.KotlinCleanup import com.ichi2.utils.VersionUtils.pkgVersionName import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response import timber.log.Timber import java.io.BufferedReader -import java.io.File import java.io.InputStreamReader -import java.net.URL import java.nio.charset.Charset import java.util.concurrent.TimeUnit const val CONN_TIMEOUT = 30000 /** - * Helper class to download from web. - *

- * Used in AsyncTasks in Translation and Pronunciation activities, and more... + * Helper class for downloads + * + * Used for Addon downloads */ object HttpFetcher { /** @@ -116,40 +110,4 @@ object HttpFetcher { "FAILED with exception: " + e.message } } - - fun downloadFileToSdCard(UrlToFile: String, context: Context, prefix: String?): String { - var str = downloadFileToSdCardMethod(UrlToFile, context, prefix, "GET") - if (str.startsWith("FAIL")) { - str = downloadFileToSdCardMethod(UrlToFile, context, prefix, "POST") - } - return str - } - - private fun downloadFileToSdCardMethod(UrlToFile: String, context: Context, prefix: String?, method: String): String { - var response: Response? = null - return try { - val url = URL(UrlToFile) - val extension = UrlToFile.substring(UrlToFile.length - 4) - val requestBuilder = Request.Builder() - requestBuilder.url(url) - if ("GET" == method) { - requestBuilder.get() - } else { - requestBuilder.post(ByteArray(0).toRequestBody(null, 0, 0)) - } - val request: Request = requestBuilder.build() - val client: OkHttpClient = getOkHttpBuilder(true).build() - response = client.newCall(request).execute() - val file = File.createTempFile(prefix!!, extension, context.cacheDir) - response.body!!.byteStream().use { inputStream -> - CompatHelper.compat.copyFile(inputStream, file.canonicalPath) - } - file.absolutePath - } catch (e: Exception) { - Timber.w(e) - "FAILED " + e.message - } finally { - response?.body?.close() - } - } } diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt b/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt new file mode 100644 index 000000000000..0486c7babe05 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt @@ -0,0 +1,323 @@ +/* + * Copyright (c) 2024 Anoop + * + * 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 . + */ + +package com.ichi2.widget.cardanalysis + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetManager.ACTION_APPWIDGET_UPDATE +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.view.View +import android.widget.RemoteViews +import com.ichi2.anki.AnkiDroidApp +import com.ichi2.anki.CrashReportService +import com.ichi2.anki.R +import com.ichi2.anki.Reviewer +import com.ichi2.anki.analytics.UsageAnalytics +import com.ichi2.anki.isCollectionEmpty +import com.ichi2.anki.pages.DeckOptions +import com.ichi2.libanki.DeckId +import com.ichi2.libanki.Decks.Companion.NOT_FOUND_DECK_ID +import com.ichi2.widget.ACTION_UPDATE_WIDGET +import com.ichi2.widget.AnalyticsWidgetProvider +import com.ichi2.widget.cancelRecurringAlarm +import com.ichi2.widget.deckpicker.DeckWidgetData +import com.ichi2.widget.deckpicker.getDeckNameAndStats +import com.ichi2.widget.setRecurringAlarm +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * This widget displays a deck with the respective new, learning, and review card counts. + * It updates every minute and if there is any changes in study queues. + * It allows user to open the reviewer directly by clicking on the deck same as deckpicker. + * It can be configured and reconfigured by holding the widget. + */ +class CardAnalysisWidget : AnalyticsWidgetProvider() { + + companion object { + + /** + * Key used for passing the selected deck ID in the intent extras. + */ + const val EXTRA_SELECTED_DECK_ID = "card_analysis_widget_selected_deck_id" + + /** + * Updates the widget with the deck data. + * + * This method updates the widget view content with the deck data corresponding + * to the provided deck ID. If the deck is deleted, the widget will be show a message "Missing deck. Please reconfigure". + * + * @param context the context of the application + * @param appWidgetManager the AppWidgetManager instance + * @param appWidgetId the ID of the app widget + */ + fun updateWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int + ) { + val deckId = getDeckIdForWidget(context, appWidgetId) + val remoteViews = RemoteViews(context.packageName, R.layout.widget_card_analysis) + + if (deckId == NOT_FOUND_DECK_ID) { + // If deckId is null, it means no deck was selected or the selected deck was deleted. + // In this case, we don't save the null value to preferences because we want to + // keep the previous deck ID if the user reconfigures the widget later. + // Instead, we show a message prompting the user to reconfigure the widget. + showMissingDeck(context, appWidgetManager, appWidgetId, remoteViews) + return + } + + AnkiDroidApp.applicationScope.launch { + val isCollectionEmpty = isCollectionEmpty() + if (isCollectionEmpty) { + showCollectionDeck(context, appWidgetManager, appWidgetId, remoteViews) + return@launch + } + + val deckData = getDeckNameAndStats(deckId) + if (deckData == null) { + // The deck was found but no data could be fetched, so update the preferences to remove the deck. + // This ensures that the widget does not retain a reference to a non-existent or invalid deck. + CardAnalysisWidgetPreferences(context).saveSelectedDeck(appWidgetId, NOT_FOUND_DECK_ID) + showMissingDeck(context, appWidgetManager, appWidgetId, remoteViews) + return@launch + } + showDeck(context, appWidgetManager, appWidgetId, remoteViews, deckData) + } + } + + private fun getDeckIdForWidget(context: Context, appWidgetId: Int): DeckId { + val widgetPreferences = CardAnalysisWidgetPreferences(context) + return widgetPreferences.getSelectedDeckIdFromPreferences(appWidgetId) ?: NOT_FOUND_DECK_ID + } + + private fun showCollectionDeck( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + remoteViews: RemoteViews + ) { + remoteViews.setTextViewText(R.id.empty_widget, context.getString(R.string.app_not_initialized_new)) + remoteViews.setViewVisibility(R.id.empty_widget, View.VISIBLE) + remoteViews.setViewVisibility(R.id.cardAnalysisDataHolder, View.GONE) + remoteViews.setViewVisibility(R.id.deckNameCardAnalysis, View.GONE) + + appWidgetManager.updateAppWidget(appWidgetId, remoteViews) + } + + private fun showMissingDeck( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + remoteViews: RemoteViews + ) { + // Show empty_widget and set click listener to open configuration + remoteViews.setViewVisibility(R.id.empty_widget, View.VISIBLE) + remoteViews.setViewVisibility(R.id.cardAnalysisDataHolder, View.GONE) + remoteViews.setViewVisibility(R.id.deckNameCardAnalysis, View.GONE) + + val configIntent = Intent(context, CardAnalysisWidgetConfig::class.java).apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + val configPendingIntent = PendingIntent.getActivity( + context, + appWidgetId, + configIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + remoteViews.setOnClickPendingIntent(R.id.empty_widget, configPendingIntent) + + appWidgetManager.updateAppWidget(appWidgetId, remoteViews) + } + + private fun showDeck( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + remoteViews: RemoteViews, + deckData: DeckWidgetData + ) { + remoteViews.setTextViewText(R.id.deckNameCardAnalysis, deckData.name) + remoteViews.setTextViewText(R.id.deckNew_card_analysis_widget, deckData.newCount.toString()) + remoteViews.setTextViewText(R.id.deckDue_card_analysis_widget, deckData.reviewCount.toString()) + remoteViews.setTextViewText(R.id.deckLearn_card_analysis_widget, deckData.learnCount.toString()) + + // Hide empty_widget and show the actual widget content + remoteViews.setViewVisibility(R.id.empty_widget, View.GONE) + remoteViews.setViewVisibility(R.id.cardAnalysisDataHolder, View.VISIBLE) + remoteViews.setViewVisibility(R.id.deckNameCardAnalysis, View.VISIBLE) + + val isEmptyDeck = deckData.newCount == 0 && deckData.reviewCount == 0 && deckData.learnCount == 0 + + val intent = if (!isEmptyDeck) { + Intent(context, Reviewer::class.java).apply { + action = Intent.ACTION_VIEW + putExtra("deckId", deckData.deckId) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + } else { + DeckOptions.getIntent(context, deckData.deckId) + } + val pendingIntent = PendingIntent.getActivity( + context, + deckData.deckId.toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + remoteViews.setOnClickPendingIntent(R.id.deckNameCardAnalysis, pendingIntent) + + appWidgetManager.updateAppWidget(appWidgetId, remoteViews) + } + + /** + * Updates the Card Analysis Widgets based on the current state of the application. + * It fetches the App Widget IDs and updates each widget with the associated deck ID. + */ + fun updateCardAnalysisWidgets(context: Context) { + val appWidgetManager = AppWidgetManager.getInstance(context) + + val provider = ComponentName(context, CardAnalysisWidget::class.java) + Timber.d("Fetching appWidgetIds for provider: $provider") + + val appWidgetIds = appWidgetManager.getAppWidgetIds(provider) + Timber.d("AppWidgetIds to update: ${appWidgetIds.joinToString(", ")}") + + for (appWidgetId in appWidgetIds) { + getDeckIdForWidget(context, appWidgetId) + updateWidget(context, appWidgetManager, appWidgetId) + } + } + } + + override fun performUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + usageAnalytics: UsageAnalytics + ) { + Timber.d("Performing widget update for appWidgetIds: %s", appWidgetIds) + + for (widgetId in appWidgetIds) { + Timber.d("Updating widget with ID: $widgetId") + + // Get the selected deck ID internally + val selectedDeckId = getDeckIdForWidget(context, widgetId) + + /** + * Explanation of behavior when selectedDeckId is empty + * If selectedDeckId is empty, the widget will retain the previous deck. + * This behavior ensures that the widget does not display an empty view, which could be + * confusing to the user. Instead, it maintains the last known state until a new valid + * deck ID is provided. This approach prioritizes providing a consistent + * user experience over showing an empty or default state. + */ + Timber.d("Selected deck ID: $selectedDeckId for widget ID: $widgetId") + + // Update the widget with the selected deck ID + updateWidget(context, appWidgetManager, widgetId) + // Set the recurring alarm for the widget + setRecurringAlarm(context, widgetId, CardAnalysisWidget::class.java) + } + + Timber.d("Widget update process completed for appWidgetIds: ${appWidgetIds.joinToString(", ")}") + } + + override fun onReceive(context: Context?, intent: Intent?) { + if (context == null || intent == null) { + Timber.e("Context or intent is null in onReceive") + return + } + super.onReceive(context, intent) + + val widgetPreferences = CardAnalysisWidgetPreferences(context) + + when (intent.action) { + ACTION_APPWIDGET_UPDATE -> { + val appWidgetManager = AppWidgetManager.getInstance(context) + + // Retrieve the widget ID from the intent + val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) + val selectedDeckId = intent.getLongExtra(EXTRA_SELECTED_DECK_ID, -1L) + + Timber.d("Received ACTION_APPWIDGET_UPDATE with widget ID: $appWidgetId and selectedDeckId: $selectedDeckId") + + if (appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) { + Timber.d("Updating widget with ID: $appWidgetId") + + // Update the widget using the internally fetched deck ID + updateWidget(context, appWidgetManager, appWidgetId) + + Timber.d("Widget update process completed for widget ID: $appWidgetId") + } + } + // Custom action to update a specific widget, triggered by the setRecurringAlarm method + ACTION_UPDATE_WIDGET -> { + val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) + if (appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) { + Timber.d("Received ACTION_UPDATE_WIDGET for widget ID: $appWidgetId") + + // Update the widget using the internally fetched deck ID + updateWidget(context, AppWidgetManager.getInstance(context), appWidgetId) + } + } + AppWidgetManager.ACTION_APPWIDGET_DELETED -> { + Timber.d("ACTION_APPWIDGET_DELETED received") + val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) + if (appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) { + Timber.d("Deleting widget with ID: $appWidgetId") + cancelRecurringAlarm(context, appWidgetId, CardAnalysisWidget::class.java) + widgetPreferences.deleteDeckData(appWidgetId) + } else { + Timber.e("Invalid widget ID received in ACTION_APPWIDGET_DELETED") + } + } + AppWidgetManager.ACTION_APPWIDGET_ENABLED -> { + Timber.d("Widget enabled") + } + AppWidgetManager.ACTION_APPWIDGET_DISABLED -> { + Timber.d("Widget disabled") + } + else -> { + Timber.e("Unexpected action received: ${intent.action}") + CrashReportService.sendExceptionReport( + Exception("Unexpected action received: ${intent.action}"), + "CardAnalysisWidget - onReceive", + null, + onlyIfSilent = true + ) + } + } + } + + override fun onDeleted(context: Context?, appWidgetIds: IntArray?) { + if (context == null) { + Timber.w("Context is null in onDeleted") + return + } + + val widgetPreferences = CardAnalysisWidgetPreferences(context) + + appWidgetIds?.forEach { widgetId -> + cancelRecurringAlarm(context, widgetId, CardAnalysisWidget::class.java) + widgetPreferences.deleteDeckData(widgetId) + } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetConfig.kt b/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetConfig.kt new file mode 100644 index 000000000000..e687d3985832 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetConfig.kt @@ -0,0 +1,362 @@ +/* + * Copyright (c) 2024 Anoop + * + * 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 . + */ + +package com.ichi2.widget.cardanalysis + +import android.appwidget.AppWidgetManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.IntentFilter +import android.os.Bundle +import android.view.View +import android.widget.Button +import androidx.activity.OnBackPressedCallback +import androidx.annotation.StringRes +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.google.android.material.snackbar.Snackbar +import com.ichi2.anki.AnkiActivity +import com.ichi2.anki.R +import com.ichi2.anki.dialogs.DeckSelectionDialog +import com.ichi2.anki.dialogs.DeckSelectionDialog.DeckSelectionListener +import com.ichi2.anki.dialogs.DeckSelectionDialog.SelectableDeck +import com.ichi2.anki.dialogs.DiscardChangesDialog +import com.ichi2.anki.isCollectionEmpty +import com.ichi2.anki.showThemedToast +import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider +import com.ichi2.anki.snackbar.SnackbarBuilder +import com.ichi2.anki.snackbar.showSnackbar +import com.ichi2.widget.WidgetConfigScreenAdapter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber + +// TODO: Ensure that the Deck Selection Dialog does not close automatically while the user is interacting with it. + +class CardAnalysisWidgetConfig : AnkiActivity(), DeckSelectionListener, BaseSnackbarBuilderProvider { + + private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID + lateinit var deckAdapter: WidgetConfigScreenAdapter + private lateinit var cardAnalysisWidgetPreferences: CardAnalysisWidgetPreferences + + /** + * Maximum number of decks allowed in the widget. + */ + private val MAX_DECKS_ALLOWED = 1 + private var hasUnsavedChanges = false + private var isAdapterObserverRegistered = false + private lateinit var onBackPressedCallback: OnBackPressedCallback + private val EXTRA_SELECTED_DECK_IDS = "card_analysis_widget_selected_deck_ids" + + override fun onCreate(savedInstanceState: Bundle?) { + if (showedActivityFailedScreen(savedInstanceState)) { + return + } + + super.onCreate(savedInstanceState) + + if (!ensureStoragePermissions()) { + return + } + + setContentView(R.layout.widget_deck_picker_config) + + cardAnalysisWidgetPreferences = CardAnalysisWidgetPreferences(this) + + appWidgetId = intent.extras?.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) ?: AppWidgetManager.INVALID_APPWIDGET_ID + + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + Timber.v("Invalid App Widget ID") + finish() + return + } + + // Check if the collection is empty before proceeding and if the collection is empty, show a toast instead of the configuration view. + lifecycleScope.launch { + if (isCollectionEmpty()) { + showThemedToast( + this@CardAnalysisWidgetConfig, + R.string.app_not_initialized_new, + false + ) + finish() + return@launch + } + + initializeUIComponents() + // Show the Deck selection dialog only when there are no decks selected while opening the configuration screen. + val selectedDeckId = cardAnalysisWidgetPreferences.getSelectedDeckIdFromPreferences(appWidgetId) + if (selectedDeckId == null) { + showDeckSelectionDialog() + } + } + } + + fun showSnackbar(message: CharSequence) { + showSnackbar( + message, + Snackbar.LENGTH_LONG + ) + } + + fun showSnackbar(@StringRes messageResId: Int) { + showSnackbar(getString(messageResId)) + } + + fun initializeUIComponents() { + deckAdapter = WidgetConfigScreenAdapter { deck, _ -> + deckAdapter.removeDeck(deck.deckId) + showSnackbar(R.string.deck_removed_from_widget) + updateViewVisibility() + updateFabVisibility() + updateSubmitButtonText() + setUnsavedChanges(true) + } + + findViewById(R.id.recyclerViewSelectedDecks).apply { + layoutManager = LinearLayoutManager(context) + adapter = this@CardAnalysisWidgetConfig.deckAdapter + } + + // Find and update the submit button text based on the initial deck selection state + val submitButton = findViewById