From f9ff1d346d6c578c10bec698b5ac571c44e34421 Mon Sep 17 00:00:00 2001 From: urikdev Date: Wed, 18 Mar 2026 16:17:35 -0500 Subject: [PATCH 1/2] Adds Manage Learned Words functionality --- PRIVACY.md | 12 +- app/build.gradle.kts | 1 + .../keyboard/data/database/LearnedWordDao.kt | 12 + .../data/database/UserWordBigramDao.kt | 9 + .../data/database/UserWordFrequencyDao.kt | 3 + .../com/urik/keyboard/di/KeyboardModule.kt | 6 + .../keyboard/service/WordLearningEngine.kt | 91 +++++++ .../keyboard/settings/SettingsActivity.kt | 2 + .../urik/keyboard/settings/SettingsEvent.kt | 8 + .../keyboard/settings/SettingsEventHandler.kt | 16 ++ .../settings/learnedwords/AlphabetSideBar.kt | 240 ++++++++++++++++++ .../learnedwords/LearnedWordsAdapter.kt | 40 +++ .../learnedwords/LearnedWordsFragment.kt | 126 +++++++++ .../learnedwords/LearnedWordsViewModel.kt | 71 ++++++ .../privacydata/PrivacyDataFragment.kt | 61 +++++ app/src/main/res/drawable/ic_delete.xml | 9 + .../res/layout/fragment_learned_words.xml | 46 ++++ app/src/main/res/layout/item_learned_word.xml | 30 +++ app/src/main/res/menu/menu_learned_words.xml | 8 + app/src/main/res/values-ar/strings.xml | 12 + app/src/main/res/values-ca/strings.xml | 12 + app/src/main/res/values-cs/strings.xml | 12 + app/src/main/res/values-de/strings.xml | 12 + app/src/main/res/values-el/strings.xml | 12 + app/src/main/res/values-es/strings.xml | 12 + app/src/main/res/values-fa/strings.xml | 12 + app/src/main/res/values-fr/strings.xml | 12 + app/src/main/res/values-it/strings.xml | 12 + app/src/main/res/values-nl/strings.xml | 12 + app/src/main/res/values-pl/strings.xml | 12 + app/src/main/res/values-pt/strings.xml | 12 + app/src/main/res/values-ru/strings.xml | 12 + app/src/main/res/values-sv/strings.xml | 12 + app/src/main/res/values-uk/strings.xml | 12 + app/src/main/res/values/strings.xml | 12 + .../InputProcessingIntegrationTest.kt | 3 + .../integration/SwipeInputIntegrationTest.kt | 3 + .../service/WordLearningEngineTest.kt | 12 + .../learnedwords/LearnedWordsAuthGateTest.kt | 38 +++ gradle/libs.versions.toml | 2 + 40 files changed, 1039 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/urik/keyboard/settings/learnedwords/AlphabetSideBar.kt create mode 100644 app/src/main/java/com/urik/keyboard/settings/learnedwords/LearnedWordsAdapter.kt create mode 100644 app/src/main/java/com/urik/keyboard/settings/learnedwords/LearnedWordsFragment.kt create mode 100644 app/src/main/java/com/urik/keyboard/settings/learnedwords/LearnedWordsViewModel.kt create mode 100644 app/src/main/res/drawable/ic_delete.xml create mode 100644 app/src/main/res/layout/fragment_learned_words.xml create mode 100644 app/src/main/res/layout/item_learned_word.xml create mode 100644 app/src/main/res/menu/menu_learned_words.xml create mode 100644 app/src/test/java/com/urik/keyboard/settings/learnedwords/LearnedWordsAuthGateTest.kt diff --git a/PRIVACY.md b/PRIVACY.md index 5738e8f5..905a263e 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,7 +1,7 @@ # Privacy Policy **Effective Date:** October 18, 2025 -**Last Updated:** March 16, 2026 +**Last Updated:** March 18, 2026 ## Introduction @@ -51,6 +51,8 @@ For privacy inquiries, contact us at the email above. **Fallback:** If your device does not have a lock screen configured, learned words are stored without encryption, and you will see a warning about this in the app. +**Viewing Learned Words:** You can view all learned words in Settings → Privacy & Data → Manage Learned Words. If your device has a lock screen configured, you will be prompted to authenticate (PIN, pattern, password, or biometric) before the list is shown. This prevents unauthorized access to your personal vocabulary. + ### 1A. Word Frequency Tracking (Encrypted) **What:** Per-word usage frequency counters stored separately from learned words. @@ -311,6 +313,9 @@ Number-only fields (e.g., OTP codes, calculators) use a direct-commit mode that ### Learned Words, Word Frequencies, and Bigram Predictions - **Retention:** Word frequency and bigram entries unused for 30 days are automatically pruned. Maximum limits are enforced (10,000 frequency entries and 50,000 bigram entries per language). Learned words are retained indefinitely until you manually delete them or uninstall the app. - **Management Options:** + - View and manage individual learned words: Settings → Privacy & Data → Manage Learned Words (authentication required if device lock screen is set) + - Delete individual learned words: Settings → Privacy & Data → Manage Learned Words → tap delete icon (removes the word from all tables: learned words, word frequencies, and bigram predictions) + - Delete all learned words: Settings → Privacy & Data → Manage Learned Words → Delete All (removes all learned words, word frequencies, and bigram predictions) - Export dictionary: Settings → Privacy & Data → Export Dictionary (exports `learned_words` table only; word frequencies and bigrams remain local) - Import dictionary: Settings → Privacy & Data → Import Dictionary - Clear all learned words: Settings → Privacy & Data → Clear Learned Words (also clears frequency and bigram data) @@ -370,6 +375,7 @@ When you uninstall Urik, Android automatically deletes: You have the right to: 1. **Access Your Data** + - View learned words: Settings → Privacy & Data → Manage Learned Words (authentication required if device lock screen is set) - View clipboard history: Long-press symbols key on keyboard - View custom key mappings: Settings → Layout & Input → Customize Keys - View error logs: Settings → Privacy & Data → Export Error Logs @@ -378,6 +384,8 @@ You have the right to: - **Note:** Word frequency counters and bigram prediction data are used internally for suggestion ranking and cannot be viewed directly through the UI 2. **Delete Your Data** + - Delete individual learned words: Settings → Privacy & Data → Manage Learned Words → tap delete icon (completely removes the word from learned words, word frequencies, and bigram predictions) + - Delete all learned words: Settings → Privacy & Data → Manage Learned Words → Delete All - Clear specific learned words: Long-press suggestions to remove - Clear specific clipboard items: Long-press symbols key → tap × button - Clear all clipboard unpinned items: Long-press symbols key → Recent tab → Delete All @@ -464,7 +472,7 @@ Urik does not knowingly collect personal information from children under 13 year - No online interactions - No advertising or tracking -Parents can review and delete any learned words through the keyboard settings. +Parents can view and delete individual or all learned words through Settings → Privacy & Data → Manage Learned Words. ## Changes to This Privacy Policy diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a166bc69..24c5aaf6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -169,6 +169,7 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.appcompat) implementation(libs.androidx.autofill) + implementation(libs.androidx.biometric) implementation(libs.material) implementation(libs.androidx.lifecycle.viewmodel.ktx) diff --git a/app/src/main/java/com/urik/keyboard/data/database/LearnedWordDao.kt b/app/src/main/java/com/urik/keyboard/data/database/LearnedWordDao.kt index b98c93a9..b142bd47 100644 --- a/app/src/main/java/com/urik/keyboard/data/database/LearnedWordDao.kt +++ b/app/src/main/java/com/urik/keyboard/data/database/LearnedWordDao.kt @@ -241,6 +241,18 @@ interface LearnedWordDao { @Query("SELECT * FROM learned_words") suspend fun getAllLearnedWords(): List + @Query("SELECT word_normalized FROM learned_words WHERE word = :displayWord LIMIT 1") + suspend fun findNormalizedByDisplayWord(displayWord: String): String? + + @Query("SELECT DISTINCT language_tag FROM learned_words WHERE word_normalized = :normalizedWord") + suspend fun findLanguagesForWord(normalizedWord: String): List + + @Query("DELETE FROM learned_words") + suspend fun clearAll(): Int + + @Query("DELETE FROM learned_words_fts") + suspend fun clearAllFts(): Int + @Transaction suspend fun importWordWithMerge(word: LearnedWord) { val existing = findExactWord(word.languageTag, word.wordNormalized) diff --git a/app/src/main/java/com/urik/keyboard/data/database/UserWordBigramDao.kt b/app/src/main/java/com/urik/keyboard/data/database/UserWordBigramDao.kt index cf61a3e9..a7a35dfc 100644 --- a/app/src/main/java/com/urik/keyboard/data/database/UserWordBigramDao.kt +++ b/app/src/main/java/com/urik/keyboard/data/database/UserWordBigramDao.kt @@ -56,6 +56,15 @@ interface UserWordBigramDao { ) suspend fun getTopBigrams(languageTag: String, limit: Int = 100): List + @Query( + """ + DELETE FROM user_word_bigram + WHERE word_a_normalized = :normalizedWord + OR word_b_normalized = :normalizedWord + """ + ) + suspend fun deleteByWord(normalizedWord: String): Int + @Query("DELETE FROM user_word_bigram WHERE language_tag = :languageTag") suspend fun clearLanguage(languageTag: String): Int diff --git a/app/src/main/java/com/urik/keyboard/data/database/UserWordFrequencyDao.kt b/app/src/main/java/com/urik/keyboard/data/database/UserWordFrequencyDao.kt index 97e0b6f5..8f906f13 100644 --- a/app/src/main/java/com/urik/keyboard/data/database/UserWordFrequencyDao.kt +++ b/app/src/main/java/com/urik/keyboard/data/database/UserWordFrequencyDao.kt @@ -57,6 +57,9 @@ interface UserWordFrequencyDao { ) suspend fun incrementFrequencyBy(languageTag: String, wordNormalized: String, amount: Int, lastUsed: Long) + @Query("DELETE FROM user_word_frequency WHERE word_normalized = :normalizedWord") + suspend fun deleteByNormalizedWord(normalizedWord: String): Int + @Query("DELETE FROM user_word_frequency WHERE language_tag = :languageTag") suspend fun clearLanguage(languageTag: String): Int diff --git a/app/src/main/java/com/urik/keyboard/di/KeyboardModule.kt b/app/src/main/java/com/urik/keyboard/di/KeyboardModule.kt index d426efe4..ca8aa5bb 100644 --- a/app/src/main/java/com/urik/keyboard/di/KeyboardModule.kt +++ b/app/src/main/java/com/urik/keyboard/di/KeyboardModule.kt @@ -91,12 +91,18 @@ object KeyboardModule { @Singleton fun provideWordLearningEngine( learnedWordDao: LearnedWordDao, + userWordFrequencyDao: UserWordFrequencyDao, + userWordBigramDao: UserWordBigramDao, + database: KeyboardDatabase, languageManager: LanguageManager, wordNormalizer: WordNormalizer, settingsRepository: SettingsRepository, cacheMemoryManager: CacheMemoryManager ): WordLearningEngine = WordLearningEngine( learnedWordDao, + userWordFrequencyDao, + userWordBigramDao, + database, languageManager, wordNormalizer, settingsRepository, diff --git a/app/src/main/java/com/urik/keyboard/service/WordLearningEngine.kt b/app/src/main/java/com/urik/keyboard/service/WordLearningEngine.kt index 8ceac90d..645767f7 100644 --- a/app/src/main/java/com/urik/keyboard/service/WordLearningEngine.kt +++ b/app/src/main/java/com/urik/keyboard/service/WordLearningEngine.kt @@ -4,8 +4,12 @@ import android.database.sqlite.SQLiteDatabaseCorruptException import android.database.sqlite.SQLiteDatabaseLockedException import android.database.sqlite.SQLiteException import android.database.sqlite.SQLiteFullException +import androidx.room.withTransaction +import com.urik.keyboard.data.database.KeyboardDatabase import com.urik.keyboard.data.database.LearnedWord import com.urik.keyboard.data.database.LearnedWordDao +import com.urik.keyboard.data.database.UserWordBigramDao +import com.urik.keyboard.data.database.UserWordFrequencyDao import com.urik.keyboard.data.database.WordSource import com.urik.keyboard.settings.KeyboardSettings import com.urik.keyboard.settings.SettingsRepository @@ -63,6 +67,9 @@ class WordLearningEngine @Inject constructor( private val learnedWordDao: LearnedWordDao, + private val userWordFrequencyDao: UserWordFrequencyDao, + private val userWordBigramDao: UserWordBigramDao, + private val database: KeyboardDatabase, private val languageManager: LanguageManager, private val wordNormalizer: WordNormalizer, settingsRepository: SettingsRepository, @@ -962,6 +969,90 @@ constructor( } } + suspend fun getAllLearnedWordsUnified(): Result> = withContext(ioDispatcher) { + try { + val learnedWords = learnedWordDao.getAllLearnedWords() + + val displayWords = learnedWords + .map { it.word } + .distinct() + .sortedWith(String.CASE_INSENSITIVE_ORDER) + + Result.success(displayWords) + } catch (e: SQLiteException) { + ErrorLogger.logException( + component = "WordLearningEngine", + severity = ErrorLogger.Severity.HIGH, + exception = e, + context = mapOf("operation" to "getAllLearnedWordsUnified") + ) + Result.failure(e) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun deleteWordCompletely(word: String): Result = withContext(ioDispatcher) { + try { + val normalized = learnedWordDao.findNormalizedByDisplayWord(word) + ?: wordNormalizer.normalize(word, languageManager.currentLanguage.value) + + database.withTransaction { + val languages = learnedWordDao.findLanguagesForWord(normalized) + languages.forEach { lang -> + learnedWordDao.removeWordComplete(lang, normalized) + } + userWordFrequencyDao.deleteByNormalizedWord(normalized) + userWordBigramDao.deleteByWord(normalized) + } + + learnedWordsCache.invalidateAll() + hotFrequencyBuffer.clear() + swipeWordsCache = emptyList() + swipeWordsCacheLanguage = "" + + Result.success(Unit) + } catch (e: SQLiteException) { + ErrorLogger.logException( + component = "WordLearningEngine", + severity = ErrorLogger.Severity.HIGH, + exception = e, + context = mapOf("operation" to "deleteWordCompletely") + ) + Result.failure(e) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun deleteAllWordsCompletely(): Result = withContext(ioDispatcher) { + try { + database.withTransaction { + learnedWordDao.clearAll() + learnedWordDao.clearAllFts() + userWordFrequencyDao.clearAll() + userWordBigramDao.clearAll() + } + + learnedWordsCache.invalidateAll() + hotFrequencyBuffer.clear() + swipeWordsCache = emptyList() + swipeWordsCacheLanguage = "" + + Result.success(Unit) + } catch (e: SQLiteException) { + ErrorLogger.logException( + component = "WordLearningEngine", + severity = ErrorLogger.Severity.CRITICAL, + exception = e, + context = mapOf("operation" to "deleteAllWordsCompletely") + ) + Result.failure(e) + } catch (e: Exception) { + Result.failure(e) + } + } + private companion object { const val LEARNED_WORDS_CACHE_SIZE = 100 const val HOT_BUFFER_MAX_SIZE = 1000 diff --git a/app/src/main/java/com/urik/keyboard/settings/SettingsActivity.kt b/app/src/main/java/com/urik/keyboard/settings/SettingsActivity.kt index 546d7b43..8ae6d3f7 100644 --- a/app/src/main/java/com/urik/keyboard/settings/SettingsActivity.kt +++ b/app/src/main/java/com/urik/keyboard/settings/SettingsActivity.kt @@ -17,6 +17,7 @@ import com.urik.keyboard.settings.appearance.AppearanceFragment import com.urik.keyboard.settings.autocorrection.AutoCorrectionFragment import com.urik.keyboard.settings.languages.LanguagesFragment import com.urik.keyboard.settings.layoutinput.LayoutInputFragment +import com.urik.keyboard.settings.learnedwords.LearnedWordsFragment import com.urik.keyboard.settings.privacydata.PrivacyDataFragment import com.urik.keyboard.settings.typingbehavior.TypingBehaviorFragment import dagger.hilt.android.AndroidEntryPoint @@ -63,6 +64,7 @@ class SettingsActivity : AppCompatActivity() { is LayoutInputFragment -> getString(R.string.layout_settings_title) is AppearanceFragment -> getString(R.string.appearance_settings_title) is PrivacyDataFragment -> getString(R.string.privacy_settings_title) + is LearnedWordsFragment -> getString(R.string.learned_words_title) else -> getString(R.string.settings_title) } supportActionBar?.title = title diff --git a/app/src/main/java/com/urik/keyboard/settings/SettingsEvent.kt b/app/src/main/java/com/urik/keyboard/settings/SettingsEvent.kt index a6a981d8..7d8a4da0 100644 --- a/app/src/main/java/com/urik/keyboard/settings/SettingsEvent.kt +++ b/app/src/main/java/com/urik/keyboard/settings/SettingsEvent.kt @@ -63,6 +63,10 @@ sealed interface SettingsEvent { data object PauseOnMisspelledToggleFailed : Error data object AutocorrectionToggleFailed : Error + + data object DeleteWordFailed : Error + + data object DeleteAllWordsFailed : Error } /** @@ -76,5 +80,9 @@ sealed interface SettingsEvent { data class DictionaryExported(val wordCount: Int) : Success data class DictionaryImported(val newWords: Int, val updatedWords: Int) : Success + + data object WordDeleted : Success + + data object AllWordsDeleted : Success } } diff --git a/app/src/main/java/com/urik/keyboard/settings/SettingsEventHandler.kt b/app/src/main/java/com/urik/keyboard/settings/SettingsEventHandler.kt index be318cad..237f40d5 100644 --- a/app/src/main/java/com/urik/keyboard/settings/SettingsEventHandler.kt +++ b/app/src/main/java/com/urik/keyboard/settings/SettingsEventHandler.kt @@ -127,6 +127,14 @@ class SettingsEventHandler(private val context: Context) { context.getString(R.string.error_update_autocorrection) } + is SettingsEvent.Error.DeleteWordFailed -> { + context.getString(R.string.error_delete_word) + } + + is SettingsEvent.Error.DeleteAllWordsFailed -> { + context.getString(R.string.error_delete_all_words) + } + is SettingsEvent.Success.LearnedWordsCleared -> { context.getString(R.string.success_learned_words_cleared) } @@ -146,6 +154,14 @@ class SettingsEventHandler(private val context: Context) { event.updatedWords ) } + + is SettingsEvent.Success.WordDeleted -> { + context.getString(R.string.success_word_deleted) + } + + is SettingsEvent.Success.AllWordsDeleted -> { + context.getString(R.string.success_all_words_deleted) + } } Toast.makeText(context, message, Toast.LENGTH_SHORT).show() diff --git a/app/src/main/java/com/urik/keyboard/settings/learnedwords/AlphabetSideBar.kt b/app/src/main/java/com/urik/keyboard/settings/learnedwords/AlphabetSideBar.kt new file mode 100644 index 00000000..2bd14e9a --- /dev/null +++ b/app/src/main/java/com/urik/keyboard/settings/learnedwords/AlphabetSideBar.kt @@ -0,0 +1,240 @@ +package com.urik.keyboard.settings.learnedwords + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Typeface +import android.util.AttributeSet +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.View +import android.view.animation.OvershootInterpolator + +class AlphabetSideBar +@JvmOverloads +constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + View(context, attrs, defStyleAttr) { + private var letters: List = emptyList() + private var selectedIndex = -1 + private var isTouching = false + private var onLetterSelected: ((String) -> Unit)? = null + + private val density = resources.displayMetrics.density + private val scaledDensity = resources.displayMetrics.scaledDensity + + private val barWidthDp = 28f + private val barCornerRadius = 14f * density + private val selectedScaleFactor = 1.8f + private val neighborScaleFactor = 1.25f + + private var touchAnimationProgress = 0f + private var activeAnimator: ValueAnimator? = null + + private val textPaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + textAlign = Paint.Align.CENTER + typeface = Typeface.DEFAULT + } + + private val selectedTextPaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + textAlign = Paint.Align.CENTER + typeface = Typeface.DEFAULT_BOLD + } + + private val barBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val selectedCirclePaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val backgroundRect = RectF() + + private var textColor = 0x99000000.toInt() + private var selectedTextColor = 0xFF1976D2.toInt() + private var barBackgroundColor = 0x0D000000 + private var barTouchBackgroundColor = 0x1A000000 + private var selectedCircleColor = 0x1A000000 + + init { + resolveThemeColors() + } + + fun setOnLetterSelectedListener(listener: (String) -> Unit) { + onLetterSelected = listener + } + + fun setLetters(newLetters: List) { + letters = newLetters + selectedIndex = -1 + requestLayout() + invalidate() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val desiredWidth = (barWidthDp * density).toInt() + paddingLeft + paddingRight + val width = resolveSize(desiredWidth, widthMeasureSpec) + val height = resolveSize(suggestedMinimumHeight, heightMeasureSpec) + setMeasuredDimension(width, height) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + if (letters.isEmpty()) return + + drawBarBackground(canvas) + + val availableHeight = height - paddingTop - paddingBottom + val letterHeight = availableHeight.toFloat() / letters.size + val baseTextSize = (letterHeight * 0.65f) + .coerceAtMost(11f * scaledDensity) + .coerceAtLeast(8f * scaledDensity) + val centerX = width / 2f + + letters.forEachIndexed { index, letter -> + val scale = getLetterScale(index) + val animatedScale = 1f + (scale - 1f) * touchAnimationProgress + val isSelected = index == selectedIndex && isTouching + + val paint = if (isSelected) selectedTextPaint else textPaint + paint.textSize = baseTextSize * animatedScale + paint.color = if (isSelected) selectedTextColor else textColor + + val y = paddingTop + letterHeight * index + letterHeight / 2f + + if (isSelected && touchAnimationProgress > 0f) { + drawSelectedIndicator(canvas, centerX, y, baseTextSize * animatedScale) + } + + val textY = y - (paint.descent() + paint.ascent()) / 2f + canvas.drawText(letter, centerX, textY, paint) + } + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (letters.isEmpty()) return false + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + isTouching = true + startAnimator(from = 0f, to = 1f, durationMs = 200, overshoot = true) + updateSelectedIndex(event.y) + parent?.requestDisallowInterceptTouchEvent(true) + return true + } + MotionEvent.ACTION_MOVE -> { + updateSelectedIndex(event.y) + return true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + isTouching = false + selectedIndex = -1 + startAnimator(from = touchAnimationProgress, to = 0f, durationMs = 150, overshoot = false) + parent?.requestDisallowInterceptTouchEvent(false) + return true + } + } + return super.onTouchEvent(event) + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + activeAnimator?.cancel() + activeAnimator = null + } + + private fun updateSelectedIndex(y: Float) { + val index = getLetterIndex(y) + if (index != selectedIndex) { + selectedIndex = index + performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) + invalidate() + onLetterSelected?.invoke(letters[index]) + } + } + + private fun startAnimator(from: Float, to: Float, durationMs: Long, overshoot: Boolean) { + activeAnimator?.cancel() + activeAnimator = ValueAnimator.ofFloat(from, to).apply { + duration = durationMs + if (overshoot) interpolator = OvershootInterpolator(1.5f) + addUpdateListener { + touchAnimationProgress = it.animatedValue as Float + invalidate() + } + start() + } + } + + private fun drawBarBackground(canvas: Canvas) { + val bgAlpha = if (isTouching) { + touchAnimationProgress + } else { + 1f - touchAnimationProgress + }.coerceIn(0f, 1f) + + barBackgroundPaint.color = blendColor(barBackgroundColor, barTouchBackgroundColor, bgAlpha) + + backgroundRect.set( + paddingLeft.toFloat(), + paddingTop.toFloat(), + (width - paddingRight).toFloat(), + (height - paddingBottom).toFloat() + ) + canvas.drawRoundRect(backgroundRect, barCornerRadius, barCornerRadius, barBackgroundPaint) + } + + private fun drawSelectedIndicator(canvas: Canvas, cx: Float, cy: Float, textSize: Float) { + val radius = textSize * 0.9f + selectedCirclePaint.color = selectedCircleColor + selectedCirclePaint.alpha = (255 * touchAnimationProgress).toInt() + canvas.drawCircle(cx, cy, radius, selectedCirclePaint) + } + + private fun getLetterScale(index: Int): Float { + if (!isTouching || selectedIndex < 0) return 1f + val distance = kotlin.math.abs(index - selectedIndex) + return when (distance) { + 0 -> selectedScaleFactor + 1 -> neighborScaleFactor + else -> 1f + } + } + + private fun getLetterIndex(y: Float): Int { + val availableHeight = height - paddingTop - paddingBottom + val relativeY = (y - paddingTop).coerceIn(0f, availableHeight.toFloat()) + val index = (relativeY / availableHeight * letters.size).toInt() + return index.coerceIn(0, letters.lastIndex) + } + + private fun resolveThemeColors() { + val typedArray = context.obtainStyledAttributes( + intArrayOf( + android.R.attr.textColorSecondary, + android.R.attr.colorPrimary, + android.R.attr.colorControlHighlight + ) + ) + textColor = typedArray.getColor(0, textColor) + selectedTextColor = typedArray.getColor(1, selectedTextColor) + val highlight = typedArray.getColor(2, 0x1A000000) + typedArray.recycle() + + barBackgroundColor = adjustAlpha(highlight, 0.3f) + barTouchBackgroundColor = adjustAlpha(highlight, 0.6f) + selectedCircleColor = adjustAlpha(selectedTextColor, 0.15f) + } + + private fun adjustAlpha(color: Int, factor: Float): Int { + val alpha = (android.graphics.Color.alpha(color) * factor).toInt().coerceIn(0, 255) + return color and 0x00FFFFFF or (alpha shl 24) + } + + private fun blendColor(from: Int, to: Int, ratio: Float): Int { + val inv = 1f - ratio + val a = (android.graphics.Color.alpha(from) * inv + android.graphics.Color.alpha(to) * ratio).toInt() + val r = (android.graphics.Color.red(from) * inv + android.graphics.Color.red(to) * ratio).toInt() + val g = (android.graphics.Color.green(from) * inv + android.graphics.Color.green(to) * ratio).toInt() + val b = (android.graphics.Color.blue(from) * inv + android.graphics.Color.blue(to) * ratio).toInt() + return android.graphics.Color.argb(a, r, g, b) + } +} diff --git a/app/src/main/java/com/urik/keyboard/settings/learnedwords/LearnedWordsAdapter.kt b/app/src/main/java/com/urik/keyboard/settings/learnedwords/LearnedWordsAdapter.kt new file mode 100644 index 00000000..e793e3bb --- /dev/null +++ b/app/src/main/java/com/urik/keyboard/settings/learnedwords/LearnedWordsAdapter.kt @@ -0,0 +1,40 @@ +package com.urik.keyboard.settings.learnedwords + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.urik.keyboard.R + +class LearnedWordsAdapter(private val onDeleteClick: (String) -> Unit) : + ListAdapter(WordDiffCallback) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WordViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_learned_word, parent, false) + return WordViewHolder(view, onDeleteClick) + } + + override fun onBindViewHolder(holder: WordViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + class WordViewHolder(itemView: View, private val onDeleteClick: (String) -> Unit) : + RecyclerView.ViewHolder(itemView) { + private val wordText: TextView = itemView.findViewById(R.id.word_text) + private val deleteButton: ImageButton = itemView.findViewById(R.id.delete_button) + + fun bind(word: String) { + wordText.text = word + deleteButton.setOnClickListener { onDeleteClick(word) } + } + } + + private object WordDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: String, newItem: String): Boolean = oldItem == newItem + override fun areContentsTheSame(oldItem: String, newItem: String): Boolean = oldItem == newItem + } +} diff --git a/app/src/main/java/com/urik/keyboard/settings/learnedwords/LearnedWordsFragment.kt b/app/src/main/java/com/urik/keyboard/settings/learnedwords/LearnedWordsFragment.kt new file mode 100644 index 00000000..54236469 --- /dev/null +++ b/app/src/main/java/com/urik/keyboard/settings/learnedwords/LearnedWordsFragment.kt @@ -0,0 +1,126 @@ +package com.urik.keyboard.settings.learnedwords + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.ProgressBar +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.core.view.MenuProvider +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.urik.keyboard.R +import com.urik.keyboard.settings.SettingsEventHandler +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class LearnedWordsFragment : Fragment() { + private lateinit var viewModel: LearnedWordsViewModel + private lateinit var eventHandler: SettingsEventHandler + private lateinit var adapter: LearnedWordsAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel = ViewModelProvider(this)[LearnedWordsViewModel::class.java] + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.fragment_learned_words, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + eventHandler = SettingsEventHandler(requireContext()) + + val recyclerView = view.findViewById(R.id.learned_words_list) + val emptyView = view.findViewById(R.id.learned_words_empty) + val loadingView = view.findViewById(R.id.learned_words_loading) + val contentView = view.findViewById(R.id.learned_words_content) + val sideBar = view.findViewById(R.id.alphabet_sidebar) + + adapter = LearnedWordsAdapter { word -> viewModel.deleteWord(word) } + val layoutManager = LinearLayoutManager(requireContext()) + recyclerView.layoutManager = layoutManager + recyclerView.adapter = adapter + + var sectionPositions = emptyMap() + + sideBar.setOnLetterSelectedListener { letter -> + val position = sectionPositions[letter] + if (position != null) { + layoutManager.scrollToPositionWithOffset(position, 0) + } + } + + requireActivity().addMenuProvider( + object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.menu_learned_words, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + if (menuItem.itemId == R.id.action_delete_all) { + showDeleteAllConfirmation() + return true + } + return false + } + }, + viewLifecycleOwner, + Lifecycle.State.RESUMED + ) + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.uiState.collect { state -> + loadingView.visibility = if (state.isLoading) View.VISIBLE else View.GONE + + if (!state.isLoading) { + if (state.words.isEmpty()) { + contentView.visibility = View.GONE + emptyView.visibility = View.VISIBLE + } else { + contentView.visibility = View.VISIBLE + emptyView.visibility = View.GONE + val positions = mutableMapOf() + state.words.forEachIndexed { index, word -> + val letter = word.firstOrNull()?.uppercase() ?: return@forEachIndexed + positions.putIfAbsent(letter, index) + } + sectionPositions = positions + sideBar.setLetters(positions.keys.toList()) + } + adapter.submitList(state.words) + } + } + } + + launch { + viewModel.events.collect { event -> + eventHandler.handle(event) + } + } + } + } + } + + private fun showDeleteAllConfirmation() { + AlertDialog + .Builder(requireContext()) + .setTitle(resources.getString(R.string.learned_words_delete_all)) + .setMessage(resources.getString(R.string.learned_words_delete_all_confirm)) + .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.deleteAllWords() } + .setNegativeButton(android.R.string.cancel, null) + .show() + } +} diff --git a/app/src/main/java/com/urik/keyboard/settings/learnedwords/LearnedWordsViewModel.kt b/app/src/main/java/com/urik/keyboard/settings/learnedwords/LearnedWordsViewModel.kt new file mode 100644 index 00000000..55c79987 --- /dev/null +++ b/app/src/main/java/com/urik/keyboard/settings/learnedwords/LearnedWordsViewModel.kt @@ -0,0 +1,71 @@ +package com.urik.keyboard.settings.learnedwords + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.urik.keyboard.service.WordLearningEngine +import com.urik.keyboard.settings.SettingsEvent +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class LearnedWordsUiState(val words: List = emptyList(), val isLoading: Boolean = true) + +@HiltViewModel +class LearnedWordsViewModel +@Inject +constructor(private val wordLearningEngine: WordLearningEngine) : ViewModel() { + private val _uiState = MutableStateFlow(LearnedWordsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() + + init { + loadWords() + } + + fun loadWords() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true) + wordLearningEngine.getAllLearnedWordsUnified() + .onSuccess { words -> + _uiState.value = LearnedWordsUiState(words = words, isLoading = false) + } + .onFailure { + _uiState.value = LearnedWordsUiState(words = emptyList(), isLoading = false) + } + } + } + + fun deleteWord(word: String) { + viewModelScope.launch { + wordLearningEngine.deleteWordCompletely(word) + .onSuccess { + _events.emit(SettingsEvent.Success.WordDeleted) + loadWords() + } + .onFailure { + _events.emit(SettingsEvent.Error.DeleteWordFailed) + } + } + } + + fun deleteAllWords() { + viewModelScope.launch { + wordLearningEngine.deleteAllWordsCompletely() + .onSuccess { + _events.emit(SettingsEvent.Success.AllWordsDeleted) + loadWords() + } + .onFailure { + _events.emit(SettingsEvent.Error.DeleteAllWordsFailed) + } + } + } +} diff --git a/app/src/main/java/com/urik/keyboard/settings/privacydata/PrivacyDataFragment.kt b/app/src/main/java/com/urik/keyboard/settings/privacydata/PrivacyDataFragment.kt index 4bc117a4..f58b572b 100644 --- a/app/src/main/java/com/urik/keyboard/settings/privacydata/PrivacyDataFragment.kt +++ b/app/src/main/java/com/urik/keyboard/settings/privacydata/PrivacyDataFragment.kt @@ -6,7 +6,11 @@ import android.os.Bundle import android.view.View import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat import androidx.core.content.FileProvider +import androidx.fragment.app.FragmentActivity import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope @@ -16,6 +20,7 @@ import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreferenceCompat import com.urik.keyboard.R import com.urik.keyboard.settings.SettingsEventHandler +import com.urik.keyboard.settings.learnedwords.LearnedWordsFragment import com.urik.keyboard.utils.ErrorLogger import dagger.hilt.android.AndroidEntryPoint import java.time.LocalDate @@ -95,6 +100,18 @@ class PrivacyDataFragment : PreferenceFragmentCompat() { } screen.addPreference(clearLearnedPref) + val manageLearnedWordsPref = + Preference(context).apply { + key = "manage_learned_words" + title = resources.getString(R.string.learned_words_manage) + summary = resources.getString(R.string.learned_words_manage_summary) + setOnPreferenceClickListener { + handleManageLearnedWordsTap() + true + } + } + screen.addPreference(manageLearnedWordsPref) + val exportDictionaryPref = Preference(context).apply { key = "export_dictionary" @@ -238,4 +255,48 @@ class PrivacyDataFragment : PreferenceFragmentCompat() { } importLauncher.launch(intent) } + + private fun handleManageLearnedWordsTap() { + val biometricManager = BiometricManager.from(requireContext()) + val canAuthenticate = biometricManager.canAuthenticate( + BiometricManager.Authenticators.DEVICE_CREDENTIAL + ) + + if (canAuthenticate == BiometricManager.BIOMETRIC_SUCCESS) { + showBiometricPrompt() + } else { + navigateToLearnedWords() + } + } + + private fun showBiometricPrompt() { + val activity = requireActivity() as FragmentActivity + val executor = ContextCompat.getMainExecutor(requireContext()) + + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + navigateToLearnedWords() + } + } + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(resources.getString(R.string.learned_words_auth_title)) + .setSubtitle(resources.getString(R.string.learned_words_auth_subtitle)) + .setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.BIOMETRIC_WEAK or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + ) + .build() + + BiometricPrompt(activity, executor, callback).authenticate(promptInfo) + } + + private fun navigateToLearnedWords() { + parentFragmentManager + .beginTransaction() + .replace(R.id.settings_container, LearnedWordsFragment()) + .addToBackStack(null) + .commit() + } } diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 00000000..6c6f704a --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/fragment_learned_words.xml b/app/src/main/res/layout/fragment_learned_words.xml new file mode 100644 index 00000000..98d8ab87 --- /dev/null +++ b/app/src/main/res/layout/fragment_learned_words.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_learned_word.xml b/app/src/main/res/layout/item_learned_word.xml new file mode 100644 index 00000000..1b8002bd --- /dev/null +++ b/app/src/main/res/layout/item_learned_word.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/app/src/main/res/menu/menu_learned_words.xml b/app/src/main/res/menu/menu_learned_words.xml new file mode 100644 index 00000000..29980a7a --- /dev/null +++ b/app/src/main/res/menu/menu_learned_words.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 759e21f8..c5e0c1da 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -179,6 +179,14 @@ حفظ الكلمات المتعلمة إلى ملف استيراد معجم إسترجاع كلمات متعلمة من ملف + الكلمات المحفوظة + إدارة الكلمات المحفوظة + عرض وحذف الكلمات المحفوظة + لا توجد كلمات محفوظة بعد + حذف الكل + حذف جميع الكلمات المحفوظة؟ لا يمكن التراجع عن هذا. + التحقق من الهوية + قم بالمصادقة لعرض الكلمات المحفوظة خطأ @@ -212,10 +220,14 @@ فشل تحديث وضع القاموس فشل تحديث إعداد التوقف فشل تحديث إعداد التصحيح التلقائي + فشل حذف الكلمة + فشل حذف الكلمات المحفوظة تم حذف الكلمات المتعلمة تم إعادة ضبط الإعدادات تم تصدير %1$d كلمة تم إضافة %1$d كلمة جديدة, وتحديث %2$d كلمات + تم حذف الكلمة + تم حذف جميع الكلمات المحفوظة لوحة المفاتيح الوهمية diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 16927f90..a8729c8a 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -169,6 +169,14 @@ Desar les paraules apreses en un arxiu Importar diccionari Restaurar les paraules apreses des d\'un arxiu + Paraules apreses + Gestionar paraules apreses + Veure i eliminar paraules apreses + Encara no hi ha paraules apreses + Eliminar tot + Eliminar totes les paraules apreses? Això no es pot desfer. + Verificar identitat + Autentiqueu-vos per veure les paraules apreses Error Ha fallat l\'actualització de la mida de la tecla @@ -195,6 +203,8 @@ Ha fallat l\'actualització del mode de diccionari Ha fallat l\'actualització del paràmetre de pausa Ha fallat l\'actualització del paràmetre d\'autocorrecció + No s\'ha pogut eliminar la paraula + No s\'han pogut eliminar les paraules apreses No s\'han esborrat les paraules apreses Ha fallat el restabliment de la configuració No s\'ha pogut exportar el diccionari @@ -204,6 +214,8 @@ Configuració reiniciada als valors per defecte %1$d paraules exportades %1$d noves afegides, %2$d paraules actualitzades + Paraula eliminada + Totes les paraules apreses eliminades Teclat virtual diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index a29c365d..f09b4e74 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -169,6 +169,14 @@ Uložit naučená slova do souboru Importovat slovník Obnovit naučená slova ze souboru + Naučená slova + Spravovat naučená slova + Zobrazit a smazat jednotlivá naučená slova + Zatím žádná naučená slova + Smazat vše + Smazat všechna naučená slova? Tuto akci nelze vrátit. + Ověřit identitu + Pro zobrazení naučených slov se ověřte Chyba @@ -196,12 +204,16 @@ Nepodařilo se aktualizovat režim slovníku Nepodařilo se aktualizovat nastavení pauzy Nepodařilo se aktualizovat nastavení automatické opravy + Nepodařilo se smazat slovo + Nepodařilo se smazat naučená slova Naučená slova smazána Obnoveno výchozí nastavení Exportováno %1$d slov Přidáno %1$d nových, aktualizováno %2$d slov + Slovo odstraněno + Všechna naučená slova smazána Virtuální klávesnice diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 13e615e3..9faa68af 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -168,6 +168,14 @@ Erlernte Wörter in eine Datei speichern Wörterbuch importieren Erlernte Wörter aus einer Datei wiederherstellen + Erlernte Wörter + Erlernte Wörter verwalten + Einzelne erlernte Wörter anzeigen und löschen + Noch keine erlernten Wörter + Alle löschen + Alle erlernten Wörter löschen? Dies kann nicht rückgängig gemacht werden. + Identität bestätigen + Authentifizieren, um erlernte Wörter anzuzeigen Fehler @@ -195,12 +203,16 @@ Wörterbuchmodus konnte nicht aktualisiert werden Pause-Einstellung konnte nicht aktualisiert werden Autokorrektur-Einstellung konnte nicht aktualisiert werden + Wort konnte nicht gelöscht werden + Erlernte Wörter konnten nicht gelöscht werden Wörterbuch wurde geleert Einstellungen wurden zurückgesetzt %1$d Wörter exportiert %1$d neue hinzugefügt, %2$d Wörter aktualisiert + Wort entfernt + Alle erlernten Wörter gelöscht Virtuelle Tastatur diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 3acf6f03..09971361 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -169,6 +169,14 @@ Αποθήκευση μαθημένων λέξεων σε αρχείο Εισαγωγή λεξικού Επαναφορά μαθημένων λέξεων από αρχείο + Αποθηκευμένες λέξεις + Διαχείριση αποθηκευμένων λέξεων + Προβολή και διαγραφή αποθηκευμένων λέξεων + Δεν υπάρχουν αποθηκευμένες λέξεις ακόμα + Διαγραφή όλων + Διαγραφή όλων των αποθηκευμένων λέξεων; Αυτό δεν μπορεί να αναιρεθεί. + Επαλήθευση ταυτότητας + Πιστοποιηθείτε για να δείτε τις αποθηκευμένες λέξεις Σφάλμα Αποτυχία ενημέρωσης μεγέθους πλήκτρου @@ -199,11 +207,15 @@ Αποτυχία ενημέρωσης λειτουργίας λεξικού Αποτυχία ενημέρωσης ρύθμισης παύσης Αποτυχία ενημέρωσης ρύθμισης αυτόματης διόρθωσης + Αποτυχία διαγραφής λέξης + Αποτυχία διαγραφής αποθηκευμένων λέξεων Οι μαθημένες λέξεις εκκαθαρίστηκαν Οι ρυθμίσεις επανήλθαν στις προεπιλογές Εξάχθηκαν %1$d λέξεις Προστέθηκαν %1$d νέες λέξεις, ενημερώθηκαν %2$d λέξεις + Η λέξη αφαιρέθηκε + Όλες οι αποθηκευμένες λέξεις διαγράφηκαν Εικονικό πληκτρολόγιο diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index d7216be7..71da1591 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -169,6 +169,14 @@ Guardar palabras aprendidas en un archivo Importar diccionario Restaurar palabras aprendidas desde un archivo + Palabras aprendidas + Gestionar palabras aprendidas + Ver y eliminar palabras aprendidas + Aún no hay palabras aprendidas + Eliminar todo + ¿Eliminar todas las palabras aprendidas? Esto no se puede deshacer. + Verificar identidad + Autentícate para ver las palabras aprendidas Error @@ -196,12 +204,16 @@ Error al actualizar el modo de diccionario Error al actualizar la configuración de pausa Error al actualizar la configuración de autocorrección + Error al eliminar la palabra + Error al eliminar las palabras aprendidas Palabras aprendidas borradas La configuración se ha restablecido a los valores predeterminados. %1$d palabras exportadas %1$d nuevas añadidas, %2$d palabras actualizadas + Palabra eliminada + Todas las palabras aprendidas eliminadas Teclado virtual diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 74dc73d1..cc6753d8 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -178,6 +178,14 @@ ذخیره واژگان آموخته‌شده در یک فایل وارد کردن فرهنگ لغت بارگذاری واژگان از فایل پشتیبان + کلمات آموخته‌شده + مدیریت کلمات آموخته‌شده + مشاهده و حذف کلمات آموخته‌شده + هنوز کلمه‌ای آموخته نشده + حذف همه + همه کلمات آموخته‌شده حذف شوند؟ این عمل قابل بازگشت نیست. + تأیید هویت + برای مشاهده کلمات آموخته‌شده احراز هویت کنید استخراج گزارش خطا به اشتراک گذاشتن گزارش‌های تشخیصی برای اشکال‌زدایی استخراج گزارش خطا ناموفق بود @@ -212,12 +220,16 @@ به‌روزرسانی حالت فرهنگ لغت ناموفق بود به‌روزرسانی تنظیم توقف انجام نشد به‌روزرسانی تنظیم تصحیح خودکار انجام نشد + حذف کلمه ناموفق بود + حذف کلمات آموخته‌شده ناموفق بود واژه‌های آموخته‌شده پاک شدند تنظیمات به حالت پیش‌فرض بازنشانی شدند %1$d واژه صادر شد %1$d واژه جدید، %2$d به‌روزرسانی‌شده + کلمه حذف شد + همه کلمات آموخته‌شده حذف شدند کیبورد مجازی diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 75c2df0e..2ab156d6 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -182,6 +182,14 @@ Enregistrer les mots appris dans un fichier Importer le dictionnaire Restaurer les mots appris à partir d\'un fichier + Mots appris + Gérer les mots appris + Afficher et supprimer les mots appris + Aucun mot appris pour le moment + Tout supprimer + Supprimer tous les mots appris ? Cette action est irréversible. + Vérifier l\'identité + Authentifiez-vous pour voir les mots appris Erreur @@ -213,12 +221,16 @@ Échec de la mise à jour du mode dictionnaire Impossible de mettre à jour le paramètre de pause Impossible de mettre à jour le paramètre d\'autocorrection + Impossible de supprimer le mot + Impossible de supprimer les mots appris Mots appris effacés Réinitialisation des paramètres par défaut %1$d mots exportés Ajout de %1$d nouveaux mots, mise à jour de %2$d mots + Mot supprimé + Tous les mots appris supprimés Clavier virtuel diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index b868413e..b9b16b05 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -182,6 +182,14 @@ Salva parole apprese in un file Importa dizionario Ripristina parole apprese da un file + Parole apprese + Gestisci parole apprese + Visualizza ed elimina singole parole apprese + Nessuna parola appresa + Elimina tutto + Eliminare tutte le parole apprese? Questa azione non può essere annullata. + Verifica identità + Autenticati per visualizzare le parole apprese Errore @@ -213,12 +221,16 @@ Impossibile aggiornare la modalità dizionario Impossibile aggiornare l\'impostazione pausa Impossibile aggiornare l\'impostazione autocorrezione + Impossibile eliminare la parola + Impossibile eliminare le parole apprese Parole apprese cancellate Impostazioni ripristinate ai valori predefiniti %1$d parole esportate %1$d nuove aggiunte, %2$d parole aggiornate + Parola rimossa + Tutte le parole apprese eliminate Tastiera virtuale diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index fc6378a8..a502f78a 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -182,6 +182,14 @@ Geleerde woorden opslaan naar bestand Woordenboek importeren Geleerde woorden herstellen uit bestand + Geleerde woorden + Geleerde woorden beheren + Bekijk en verwijder geleerde woorden + Nog geen geleerde woorden + Alles verwijderen + Alle geleerde woorden verwijderen? Dit kan niet ongedaan worden gemaakt. + Identiteit verifiëren + Verifieer om geleerde woorden te bekijken Fout @@ -213,12 +221,16 @@ Woordenboekmodus bijwerken mislukt Kan pauze-instelling niet bijwerken Kan autocorrectie-instelling niet bijwerken + Kan woord niet verwijderen + Kan geleerde woorden niet verwijderen Gewilde woorden gewist Instellingen teruggezet naar standaardwaarden %1$d woorden geëxporteerd %1$d nieuwe toegevoegd, %2$d woorden bijgewerkt + Woord verwijderd + Alle geleerde woorden gewist Virtueel toetsenbord diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index d0b2ad94..0d38eac3 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -170,6 +170,14 @@ Zapisz nauczone słowa do pliku Importuj słownik Przywróć nauczone słowa z pliku + Nauczone słowa + Zarządzaj nauczonymi słowami + Przeglądaj i usuwaj nauczone słowa + Brak nauczonych słów + Usuń wszystko + Usunąć wszystkie nauczone słowa? Tej operacji nie można cofnąć. + Zweryfikuj tożsamość + Uwierzytelnij się, aby zobaczyć nauczone słowa Błąd @@ -197,12 +205,16 @@ Nie udało się zaktualizować trybu słownika Nie udało się zaktualizować ustawienia pauzy Nie udało się zaktualizować ustawienia autokorekty + Nie udało się usunąć słowa + Nie udało się usunąć nauczonych słów Nauczone słowa wyczyszczone Ustawienia przywrócone do domyślnych Wyeksportowano %1$d słów Dodano %1$d nowych, zaktualizowano %2$d słów + Słowo usunięte + Wszystkie nauczone słowa usunięte Klawiatura wirtualna diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 791fd333..485159b6 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -169,6 +169,14 @@ Guardar palavras aprendidas num ficheiro Importar dicionário Restaurar palavras aprendidas de um ficheiro + Palavras aprendidas + Gerir palavras aprendidas + Ver e apagar palavras aprendidas + Ainda sem palavras aprendidas + Apagar tudo + Apagar todas as palavras aprendidas? Esta ação não pode ser revertida. + Verificar identidade + Autentique-se para ver as palavras aprendidas Erro @@ -196,12 +204,16 @@ Falha ao atualizar modo do dicionário Falha ao atualizar configuração de pausa Falha ao atualizar configuração de autocorreção + Falha ao apagar palavra + Falha ao apagar palavras aprendidas Palavras aprendidas apagadas As configurações foram restauradas para os valores padrão. %1$d palavras exportadas %1$d novas adicionadas, %2$d palavras atualizadas + Palavra removida + Todas as palavras aprendidas apagadas Teclado virtual diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7bf491e5..b5558a00 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -171,6 +171,14 @@ Сохранить изученные слова в файл Импорт словаря Восстановить изученные слова из файла + Выученные слова + Управление выученными словами + Просмотр и удаление выученных слов + Выученных слов пока нет + Удалить все + Удалить все выученные слова? Это действие нельзя отменить. + Подтвердите личность + Авторизуйтесь для просмотра выученных слов Ошибка @@ -199,12 +207,16 @@ Не удалось обновить режим словаря Не удалось обновить настройку паузы Не удалось обновить настройку автокоррекции + Не удалось удалить слово + Не удалось удалить выученные слова Запомненные слова удалены Настройки сброшены до значений по умолчанию Экспортировано %1$d слов Добавлено %1$d новых, обновлено %2$d слов + Слово удалено + Все выученные слова удалены Виртуальная клавиатура diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index bbafd835..33188390 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -139,10 +139,14 @@ Kunde inte uppdatera ordboksläge Kunde inte uppdatera pausinställning Kunde inte uppdatera autokorrigeringsinställning + Kunde inte ta bort ordet + Kunde inte ta bort inlärda ord Inlärda ord rensade Inställningarna återställda till standardinställningarna %1$d ord exporterade %1$d nya ord, %2$d uppdaterade + Ordet borttaget + Alla inlärda ord borttagna Virtuellt tangentbord ABC 123 @@ -204,6 +208,14 @@ Spara dina inlärda ord till en fil Importera ordbok Läs in inlärda ord från en säkerhetskopia + Inlärda ord + Hantera inlärda ord + Visa och ta bort inlärda ord + Inga inlärda ord ännu + Ta bort alla + Ta bort alla inlärda ord? Detta kan inte ångras. + Verifiera identitet + Autentisera för att visa inlärda ord Exportera fellogg Dela diagnostikloggar för felsökning Misslyckades med att exportera felloggen diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 42139a09..af35809a 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -170,6 +170,14 @@ Зберегти вивчені слова у файл Імпорт словника Відновити вивчені слова з файлу + Вивчені слова + Керування вивченими словами + Перегляд і видалення вивчених слів + Вивчених слів ще немає + Видалити все + Видалити всі вивчені слова? Цю дію не можна скасувати. + Підтвердити особу + Автентифікуйтесь для перегляду вивчених слів Помилка @@ -197,12 +205,16 @@ Не вдалося оновити режим словника Не вдалося оновити налаштування паузи Не вдалося оновити налаштування автокорекції + Не вдалося видалити слово + Не вдалося видалити вивчені слова Запам\'ятовані слова видалені Налаштування скинуто до стандартних Експортовано %1$d слів Додано %1$d нових, оновлено %2$d слів + Слово видалено + Усі вивчені слова видалено Віртуальна клавіатура diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6f019e0d..a3cf6d21 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -190,6 +190,14 @@ Save learned words to a file Import Dictionary Restore learned words from a file + Learned Words + Manage Learned Words + View and delete individual learned words + No learned words yet + Delete All + Delete all learned words? This cannot be undone. + Verify identity + Authenticate to view learned words Error @@ -221,12 +229,16 @@ Failed to update dictionary mode Failed to update pause setting Failed to update autocorrection setting + Failed to delete word + Failed to delete learned words Learned words cleared Settings reset to defaults Exported %1$d words Added %1$d new, updated %2$d words + Word removed + All learned words cleared Virtual keyboard diff --git a/app/src/test/java/com/urik/keyboard/integration/InputProcessingIntegrationTest.kt b/app/src/test/java/com/urik/keyboard/integration/InputProcessingIntegrationTest.kt index c48da3eb..3bd2e1e6 100644 --- a/app/src/test/java/com/urik/keyboard/integration/InputProcessingIntegrationTest.kt +++ b/app/src/test/java/com/urik/keyboard/integration/InputProcessingIntegrationTest.kt @@ -112,6 +112,9 @@ class InputProcessingIntegrationTest { wordLearningEngine = WordLearningEngine( database.learnedWordDao(), + database.userWordFrequencyDao(), + database.userWordBigramDao(), + database, languageManager, wordNormalizer, settingsRepository, diff --git a/app/src/test/java/com/urik/keyboard/integration/SwipeInputIntegrationTest.kt b/app/src/test/java/com/urik/keyboard/integration/SwipeInputIntegrationTest.kt index ca005e75..a3a46623 100644 --- a/app/src/test/java/com/urik/keyboard/integration/SwipeInputIntegrationTest.kt +++ b/app/src/test/java/com/urik/keyboard/integration/SwipeInputIntegrationTest.kt @@ -126,6 +126,9 @@ class SwipeInputIntegrationTest { wordLearningEngine = WordLearningEngine( database.learnedWordDao(), + database.userWordFrequencyDao(), + database.userWordBigramDao(), + database, languageManager, wordNormalizer, settingsRepository, diff --git a/app/src/test/java/com/urik/keyboard/service/WordLearningEngineTest.kt b/app/src/test/java/com/urik/keyboard/service/WordLearningEngineTest.kt index eb3a52bc..cd27c52d 100644 --- a/app/src/test/java/com/urik/keyboard/service/WordLearningEngineTest.kt +++ b/app/src/test/java/com/urik/keyboard/service/WordLearningEngineTest.kt @@ -2,8 +2,11 @@ package com.urik.keyboard.service +import com.urik.keyboard.data.database.KeyboardDatabase import com.urik.keyboard.data.database.LearnedWord import com.urik.keyboard.data.database.LearnedWordDao +import com.urik.keyboard.data.database.UserWordBigramDao +import com.urik.keyboard.data.database.UserWordFrequencyDao import com.urik.keyboard.data.database.WordSource import com.urik.keyboard.settings.KeyboardSettings import com.urik.keyboard.settings.SettingsRepository @@ -42,6 +45,9 @@ class WordLearningEngineTest { private val testDispatcher = UnconfinedTestDispatcher() private lateinit var learnedWordDao: LearnedWordDao + private lateinit var userWordFrequencyDao: UserWordFrequencyDao + private lateinit var userWordBigramDao: UserWordBigramDao + private lateinit var database: KeyboardDatabase private lateinit var languageManager: LanguageManager private lateinit var settingsRepository: SettingsRepository @@ -71,6 +77,9 @@ class WordLearningEngineTest { onBlocking { cleanupLowFrequencyWords(any()) } doReturn 0 } + userWordFrequencyDao = mock() + userWordBigramDao = mock() + database = mock() languageManager = mock() settingsRepository = mock() cacheMemoryManager = mock() @@ -88,6 +97,9 @@ class WordLearningEngineTest { wordLearningEngine = WordLearningEngine( learnedWordDao = learnedWordDao, + userWordFrequencyDao = userWordFrequencyDao, + userWordBigramDao = userWordBigramDao, + database = database, languageManager = languageManager, wordNormalizer = WordNormalizer(), settingsRepository = settingsRepository, diff --git a/app/src/test/java/com/urik/keyboard/settings/learnedwords/LearnedWordsAuthGateTest.kt b/app/src/test/java/com/urik/keyboard/settings/learnedwords/LearnedWordsAuthGateTest.kt new file mode 100644 index 00000000..8fa76a13 --- /dev/null +++ b/app/src/test/java/com/urik/keyboard/settings/learnedwords/LearnedWordsAuthGateTest.kt @@ -0,0 +1,38 @@ +package com.urik.keyboard.settings.learnedwords + +import androidx.biometric.BiometricManager +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LearnedWordsAuthGateTest { + @Test + fun `BIOMETRIC_SUCCESS requires authentication`() { + assertTrue(shouldRequireAuth(BiometricManager.BIOMETRIC_SUCCESS)) + } + + @Test + fun `BIOMETRIC_ERROR_NONE_ENROLLED skips authentication`() { + assertFalse(shouldRequireAuth(BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED)) + } + + @Test + fun `BIOMETRIC_ERROR_NO_HARDWARE skips authentication`() { + assertFalse(shouldRequireAuth(BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE)) + } + + @Test + fun `BIOMETRIC_ERROR_HW_UNAVAILABLE skips authentication`() { + assertFalse(shouldRequireAuth(BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE)) + } + + @Test + fun `BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED skips authentication`() { + assertFalse(shouldRequireAuth(BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED)) + } + + companion object { + fun shouldRequireAuth(canAuthenticateResult: Int): Boolean = + canAuthenticateResult == BiometricManager.BIOMETRIC_SUCCESS + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index af979926..3aff4135 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,7 @@ androidDatabaseSqlcipher = "4.13.0" appcompat = "1.7.1" autofill = "1.3.0" +biometric = "1.1.0" coreTesting = "2.2.0" datastorePreferences = "1.2.1" emoji2Emojipicker = "1.6.0" @@ -28,6 +29,7 @@ window = "1.5.1" android-database-sqlcipher = { module = "net.zetetic:sqlcipher-android", version.ref = "androidDatabaseSqlcipher" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } androidx-autofill = { module = "androidx.autofill:autofill", version.ref = "autofill" } +androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } From 24eb8379fd87c5364d2a161e2a2a11d1eb844fde Mon Sep 17 00:00:00 2001 From: urikdev Date: Wed, 18 Mar 2026 16:52:06 -0500 Subject: [PATCH 2/2] Lint fixes --- .../settings/learnedwords/AlphabetSideBar.kt | 25 +++++++++++-------- app/src/main/res/layout/item_learned_word.xml | 3 ++- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/urik/keyboard/settings/learnedwords/AlphabetSideBar.kt b/app/src/main/java/com/urik/keyboard/settings/learnedwords/AlphabetSideBar.kt index 2bd14e9a..49a8277c 100644 --- a/app/src/main/java/com/urik/keyboard/settings/learnedwords/AlphabetSideBar.kt +++ b/app/src/main/java/com/urik/keyboard/settings/learnedwords/AlphabetSideBar.kt @@ -7,10 +7,12 @@ import android.graphics.Paint import android.graphics.RectF import android.graphics.Typeface import android.util.AttributeSet +import android.util.TypedValue import android.view.HapticFeedbackConstants import android.view.MotionEvent import android.view.View import android.view.animation.OvershootInterpolator +import androidx.annotation.AttrRes class AlphabetSideBar @JvmOverloads @@ -207,23 +209,24 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 } private fun resolveThemeColors() { - val typedArray = context.obtainStyledAttributes( - intArrayOf( - android.R.attr.textColorSecondary, - android.R.attr.colorPrimary, - android.R.attr.colorControlHighlight - ) - ) - textColor = typedArray.getColor(0, textColor) - selectedTextColor = typedArray.getColor(1, selectedTextColor) - val highlight = typedArray.getColor(2, 0x1A000000) - typedArray.recycle() + textColor = resolveColorAttr(android.R.attr.textColorSecondary, textColor) + selectedTextColor = resolveColorAttr(android.R.attr.colorPrimary, selectedTextColor) + val highlight = resolveColorAttr(android.R.attr.colorControlHighlight, 0x1A000000) barBackgroundColor = adjustAlpha(highlight, 0.3f) barTouchBackgroundColor = adjustAlpha(highlight, 0.6f) selectedCircleColor = adjustAlpha(selectedTextColor, 0.15f) } + private fun resolveColorAttr(@AttrRes attr: Int, fallback: Int): Int { + val typedValue = TypedValue() + return if (context.theme.resolveAttribute(attr, typedValue, true)) { + typedValue.data + } else { + fallback + } + } + private fun adjustAlpha(color: Int, factor: Float): Int { val alpha = (android.graphics.Color.alpha(color) * factor).toInt().coerceIn(0, 255) return color and 0x00FFFFFF or (alpha shl 24) diff --git a/app/src/main/res/layout/item_learned_word.xml b/app/src/main/res/layout/item_learned_word.xml index 1b8002bd..dafce329 100644 --- a/app/src/main/res/layout/item_learned_word.xml +++ b/app/src/main/res/layout/item_learned_word.xml @@ -1,5 +1,6 @@ + app:tint="?attr/colorOnSurfaceVariant" />